Compare commits

..
Author SHA1 Message Date
Alano Terblanche a195d24d1f cli/command: look up registry credentials through internal/registryauth
RetrieveAuthTokenFromImage is exported and imported by other projects, so it keeps reading only the configuration file. The commands that pull and push call registryauth.EncodedAuth instead, which asks DOCKER_AUTH_CONFIG, then the Docker Desktop session, then the configuration file. This drops command.EncodedRegistryAuth from the public API.

Each DockerCli owns its Docker Desktop sessions through RegistryAuthProvider, created on first use, instead of package-level sessions shared by every caller. A command.Cli without RegistryAuthProvider uses only its configuration file. The fallback reads the current configuration file, since Initialize replaces it.

Remove the session fields and the constructors that only tests used, move the staging and dhi.io hostnames into internal/secretsengine, and use testing/synctest instead of a fake clock, so that the tests run in parallel. Only the DOCKER_AUTH_CONFIG test still sets the environment.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-26 11:07:48 +01:00
Alano Terblanche 59e40e6347 internal/secretsengine: unexport what only tests use
Provider, HubClient, and New were only used inside the package and by FakeCli. FakeCli now returns its configuration file as the auth provider, which is what the signed-out Docker Hub stub fell back to. NewDesktop returns an AuthProvider. Remove DockerCli.RegistryAuthProvider: EncodedRegistryAuth already falls back to RetrieveAuthTokenFromImage, which uses the Docker Desktop session, so the method only added to the public API.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-23 08:29:09 +02:00
Alano Terblanche ec9ac08675 cli/command: test credential precedence with Docker Desktop
Check that the Docker Desktop session is used for Docker Hub and dhi.io, that other registries use the configuration file, and that DOCKER_AUTH_CONFIG takes precedence over Docker Desktop. TestRetrieveAuthTokenFromImage disables the Docker Desktop lookup.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 16:55:12 +02:00
Alano Terblanche 1afa7ac002 internal/test: keep FakeCli from contacting Docker Desktop
Tests that use FakeCli pull, push, and create containers through RetrieveAuthTokenFromImage. Disable the Docker Desktop lookup, so that they never reach the Docker Desktop of the machine they run on.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 11:22:04 +02:00
Alano Terblanche f59d490370 internal/secretsengine: test and benchmark against a secrets engine socket
Run lookups against a fake secrets engine on a unix socket: signed in, signed out, no socket, a socket with nothing listening, and an engine that never answers. The benchmarks cover the same cases. BenchmarkExternalEngine runs against the engine at SECRETSENGINE_BENCH_SOCKET when it is set.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 11:22:00 +02:00
Alano Terblanche c4e317145e internal/secretsengine: add unit tests
Cover which registries use the Docker Desktop session, keeping the production and staging sessions apart, caching the session until it expires, the retry interval after a failed lookup, and Disable.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 11:21:56 +02:00
Alano Terblanche a46376f216 cli/command: use the Docker Desktop session for Docker Hub
When Docker Desktop is running and signed in, read the Docker Hub session from the Docker Desktop secrets engine, and send it as the registry credentials for Docker Hub (docker.io) and Docker Hardened Images (dhi.io) when pulling or pushing images through the daemon: docker pull, push, run, create, plugin install and push, service create and update, and stack deploy.

Credentials in DOCKER_AUTH_CONFIG take precedence for the registries they cover. Without a usable Docker Desktop session (no socket, nothing listening, signed out, or no answer within 5 seconds), the CLI uses its credential store, credential helpers, and configuration file as before. docker login and stored credentials are not changed.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 11:21:50 +02:00
Alano Terblanche 0c2d0f700c vendor: github.com/docker/secrets-engine/client v0.2.2
Add the Docker secrets engine client, used to read the Docker Desktop session for Docker Hub. It brings in github.com/docker/secrets-engine/x v0.8.5 and connectrpc.com/connect v1.19.1.

The client module requires go1.26.10, which raises the go directive in vendor.mod from 1.26.0.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2026-10-22 11:10:40 +02:00
Sebastiaan van Stijn 6d274590f2 Merge pull request #7414 from thaJeztah/interactive_login_nits
internal/oauth: assorted fixes and cleanups
2026-10-20 14:40:35 +02:00
Sebastiaan van Stijn 4e09976ddd Merge pull request #7419 from thaJeztah/bump_sortorder
vendor: github.com/fvbommel/sortorder v1.2.1
2026-10-20 13:23:25 +02:00
Sebastiaan van Stijn 69c9ccd358 Merge pull request #7417 from vvoland/update-go
Update to go1.26.10
2026-10-20 13:11:47 +02:00
Sebastiaan van Stijn 1600418944 vendor: github.com/fvbommel/sortorder v1.2.1
full diff: https://github.com/fvbommel/sortorder/compare/v1.2.0...v1.2.1

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-20 12:46:38 +02:00
Paweł Gronowski 1fab379bcc Update to go1.26.10
This release includes 1 security fix following the security policy:

- crypto/x509: mitigation for security concerns with a specific Certificate Authority

  We're blocking trust in a small set of intermediate certificates due to specific security concerns around their operation.

  Verify will now filter out chains which contain these intermediates, preventing them from being returned to the caller.

  We highly recommend immediately upgrading to this release.

  This is CVE-2026-111943 and Go issue https://go.dev/issue/82345.

release notes: https://go.dev/doc/devel/release#go1.26.10

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-20 12:35:48 +02:00
Sebastiaan van Stijn df9a0f430f internal/oauth: remove debug logs, and move them to the caller
Wrap errors in the OAuth manager instead of logging them, preserving
the underlying cause while allowing callers to detect startup failures.

Log failures at the fallback handler and scope the device-code login
result to its branch.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-19 21:07:33 +02:00
Sebastiaan van Stijn fa65e9a3a0 internal/oauth: cleanup output
- Remove extra newline before message
- Include the token in the URL (instead of requiring the user to manually
  enter it on the website).
- Improve the "call to action"; ENTER can be used to automatically open
  the browser (but the user can manually open it instead), and CTRL-C can
  be used to cancel the login.
- Remove the "Waiting for authentication in the browser…" message, which
  could be confusing if no browser was opened (yet), instead print a message
  after successfully requesting the browser to be opened.

When running `docker login`;

    docker login

    i Info → Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'

    Your one-time device confirmation code is: NRJL-BLRL
    Open this URL to authenticate: https://login.docker.com/activate?user_code=NRJL-BLRL

    Press ENTER to open the URL in your browser, or Ctrl+C to cancel.

After pressing `ENTER`, it either prints the "success" message;

    Press ENTER to open the URL in your browser, or Ctrl+C to cancel.

    Opening browser…

or a failure when unable to launch a browser;

    Press ENTER to open the URL in your browser, or Ctrl+C to cancel.

    No browser found. Open the URL above manually to continue.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-19 21:07:27 +02:00
Sebastiaan van Stijn dc66c2a8d0 internal/oauth: handle error when failing to open browser
Before this patch, pressing `ENTER` to open the browser would silently
discard the error;

    docker login

    i Info → Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'

    Your one-time device confirmation code is: VGTB-VKBG
    Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate

    Waiting for authentication in the browser…

With this patch, a failure is printed, so that the user can manually open
the URL (or cancel the login);

    docker login

    i Info → Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'

    Your one-time device confirmation code is: SZFX-BSGP
    Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate

    Waiting for authentication in the browser…

    No browser found. Open the URL above manually to continue.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-19 21:05:24 +02:00
Sebastiaan van Stijn ae317ab44a internal/oauth: combine "web-based login" message with info note
Make the output less verbose, and combine the "web-based login" message
with the info message that's already printed.

Before this patch:

    docker login

    USING WEB-BASED LOGIN

    i Info → To sign in with credentials on the command line, use 'docker login -u <username>'

    Your one-time device confirmation code is: VVPV-NSSL
    Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate

    Waiting for authentication in the browser…

With this patch:

    docker login

    i Info → Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'

    Your one-time device confirmation code is: QBBD-PQNS
    Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate

    Waiting for authentication in the browser…

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-19 21:05:18 +02:00
Sebastiaan van Stijn 3f2ad8bf05 internal/oauth: avoid opening browser after login cancellation
Check the context after reading stdin to avoid launching the browser
if login was canceled while waiting for ENTER.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-19 20:49:29 +02:00
Paweł Gronowski 6f8369014a Merge pull request #7408 from GitNimay/3833-unknown-command-version-flag
cmd/docker: fail on unknown command with --version
2026-10-19 17:06:27 +02:00
Nimesh Kulkarni e08aa72569 cmd/docker: fail on unknown command with --version
Running "docker nonexistent --version" printed the CLI version and
exited 0, so it couldn't be used to check whether a command or plugin
is available. Without "--version" the same invocation fails with an
"unknown command" error.

When no built-in command or plugin matches, runDocker falls through to
executing the root command, and cobra handles the "--version" flag
before the root command reports the unknown command. Unset the root
command's version in that fallback, so the standard unknown-command
error is returned.

A global "--version" flag ("docker --version nonexistent") is parsed
before the command name and still prints the version, as do
"docker --version" and "docker nonexistent --help".

Signed-off-by: Nimesh Kulkarni <167794819+GitNimay@users.noreply.github.com>
2026-10-19 19:35:05 +05:30
Paweł Gronowski f522fffe52 Merge pull request #7412 from vvoland/trust-clean
cmd/docker-trust: Inline some cli dependencies
2026-10-19 14:32:57 +02:00
Paweł Gronowski 5e1e84ee5d cmd/docker-trust: Use standard regexps for name validation
The plugin only used lazyregexp for two small, fixed name patterns.
Replaced it with regexp.MustCompile without changing validation.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-19 13:28:28 +02:00
Paweł Gronowski 00bf2d970d cmd/docker-trust: Remove unused command registration
Both entry points use NewRootCmd, which adds subcommands directly.
Removed the unused registry registration and constructor.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-19 13:28:28 +02:00
Paweł Gronowski 4a93a41589 cmd/docker-trust: Inline the JSON stream display helper
The plugin only needed progress display and an auxiliary callback.
Inlined that use while preserving cancellation and terminal handling.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-19 13:28:28 +02:00
Sebastiaan van Stijn e300e529d5 Merge pull request #2012 from kolyshkin/build-man
man/docker-build: fix/expand --build-arg
2026-10-19 10:43:49 +02:00
Kir Kolyshkin 72f6e50c62 man/docker-build: fix/expand --build-arg
Explain that variable value is optional and is taken from environment
if not set.

Clarify the meaning of ARG, and mention that ENV overrides ARG of the
same name.

Move example to the end, and refer to the Dockerfile and buildx docs
for more details (replacing the obsolete link).

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
2026-10-19 01:15:00 -07:00
Sebastiaan van Stijn 52a2671034 Merge pull request #7396 from docker/dependabot/github_actions/codeql-actions-073aea62d7
build(deps): bump the codeql-actions group with 3 updates
2026-10-16 16:24:33 +02:00
Sebastiaan van Stijn 1d02640576 Merge pull request #7391 from vvoland/revert-go
Revert "chore(deps): update dependency go to v1.27.1"
2026-10-16 16:23:50 +02:00
Paweł Gronowski 4d74583397 Merge pull request #7398 from thaJeztah/bump_version
bump version to 29.10.0
2026-10-16 14:46:12 +02:00
Sebastiaan van Stijn de4ddc4fae bump version to 29.10.0
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-15 21:32:57 +02:00
dependabot[bot] ccac941dd9 build(deps): bump the codeql-actions group with 3 updates
Bumps the codeql-actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/autobuild](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/autobuild` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-15 08:45:04 +00:00
Paweł Gronowski e6beb40c03 Revert "chore(deps): update dependency go to v1.27.1"
This reverts commit a3a2bb6118.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-14 21:00:31 +02:00
98 changed files with 16573 additions and 90 deletions
+3 -3
View File
@@ -68,14 +68,14 @@ jobs:
cache: false
-
name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: go
-
name: Autobuild
uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/autobuild@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
-
name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:go"
+1 -1
View File
@@ -1 +1 @@
29.9.2
29.10.0
+19
View File
@@ -16,6 +16,7 @@ import (
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
configtypes "github.com/docker/cli/cli/config/types"
dcontext "github.com/docker/cli/cli/context"
"github.com/docker/cli/cli/context/docker"
"github.com/docker/cli/cli/context/store"
@@ -23,6 +24,7 @@ import (
cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/cli/version"
"github.com/docker/cli/internal/secretsengine"
dopts "github.com/docker/cli/opts"
"github.com/moby/moby/api/types/build"
"github.com/moby/moby/client"
@@ -75,6 +77,8 @@ type DockerCli struct {
contextStoreConfig *store.Config
initTimeout time.Duration
res telemetryResource
registryAuthOnce sync.Once
registryAuth secretsengine.AuthProvider
// baseCtx is the base context used for internal operations. In the future
// this may be replaced by explicitly passing a context to functions that
@@ -141,6 +145,21 @@ func (cli *DockerCli) ConfigFile() *configfile.ConfigFile {
return cli.configFile
}
func (cli *DockerCli) RegistryAuthProvider() secretsengine.AuthProvider {
cli.registryAuthOnce.Do(func() {
cli.registryAuth = secretsengine.NewDesktop(configFileAuth{cli})
})
return cli.registryAuth
}
type configFileAuth struct {
cli *DockerCli
}
func (c configFileAuth) GetAuthConfig(registryHostname string) (configtypes.AuthConfig, error) {
return c.cli.ConfigFile().GetAuthConfig(registryHostname)
}
// ServerInfo returns the server version details for the host this client is
// connected to
func (cli *DockerCli) ServerInfo() ServerInfo {
+15
View File
@@ -18,6 +18,7 @@ import (
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/context/store"
"github.com/docker/cli/cli/flags"
"github.com/moby/moby/client"
@@ -501,3 +502,17 @@ func TestNewDockerCliWithAPIClientOptions(t *testing.T) {
assert.NilError(t, err)
assert.DeepEqual(t, received, "fake-agent/0.0.1")
}
func TestRegistryAuthProvider(t *testing.T) {
t.Parallel()
cli := &DockerCli{configFile: &configfile.ConfigFile{}}
p := cli.RegistryAuthProvider()
assert.Assert(t, p == cli.RegistryAuthProvider(), "the provider must be created once per DockerCli")
cli.configFile = &configfile.ConfigFile{AuthConfigs: map[string]configtypes.AuthConfig{
"ghcr.io": {Username: "user", Password: "pass"},
}}
got, err := p.GetAuthConfig("ghcr.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, configtypes.AuthConfig{Username: "user", Password: "pass"})
}
+2 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/jsonstream"
"github.com/docker/cli/internal/registryauth"
"github.com/docker/cli/opts"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/client"
@@ -133,7 +134,7 @@ func runCreate(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet,
}
func pullImage(ctx context.Context, dockerCLI command.Cli, img string, options *createOptions) error {
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), img)
encodedAuth, err := registryauth.EncodedAuth(dockerCLI, img)
if err != nil {
return err
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/jsonstream"
"github.com/docker/cli/internal/registryauth"
"github.com/moby/moby/client"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/cobra"
@@ -90,7 +91,7 @@ func runPull(ctx context.Context, dockerCLI command.Cli, opts pullOptions) error
ociPlatforms = append(ociPlatforms, p)
}
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), distributionRef.String())
encodedAuth, err := registryauth.EncodedAuth(dockerCLI, distributionRef.String())
if err != nil {
return err
}
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/jsonstream"
"github.com/docker/cli/internal/registryauth"
"github.com/docker/cli/internal/tui"
"github.com/moby/moby/api/types/auxprogress"
"github.com/moby/moby/client"
@@ -108,7 +109,7 @@ To push the complete multi-platform image, remove the --platform flag.
}
// Resolve the Auth config relevant for this server
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCli.ConfigFile(), ref.String())
encodedAuth, err := registryauth.EncodedAuth(dockerCli, ref.String())
if err != nil {
return err
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/jsonstream"
"github.com/docker/cli/internal/prompt"
"github.com/docker/cli/internal/registryauth"
"github.com/moby/moby/api/types/plugin"
"github.com/moby/moby/client"
"github.com/spf13/cobra"
@@ -60,7 +61,7 @@ func buildPullConfig(dockerCLI command.Cli, opts pluginOptions) (client.PluginIn
return client.PluginInstallOptions{}, err
}
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), ref.String())
encodedAuth, err := registryauth.EncodedAuth(dockerCLI, ref.String())
if err != nil {
return client.PluginInstallOptions{}, err
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/jsonstream"
"github.com/docker/cli/internal/registryauth"
"github.com/moby/moby/client"
"github.com/spf13/cobra"
)
@@ -42,7 +43,7 @@ func runPush(ctx context.Context, dockerCli command.Cli, name string) error {
}
named = reference.TagNameOnly(named)
encodedAuth, err := command.RetrieveAuthTokenFromImage(dockerCli.ConfigFile(), named.String())
encodedAuth, err := registryauth.EncodedAuth(dockerCli, named.String())
if err != nil {
return err
}
+6 -6
View File
@@ -19,6 +19,7 @@ import (
"github.com/docker/cli/internal/tui"
registrytypes "github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -226,7 +227,7 @@ func loginWithStoredCredentials(ctx context.Context, dockerCLI command.Cli, auth
return resp.Auth.Status, err
}
func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, defaultUsername, serverAddress string) (msg string, _ error) {
func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, defaultUsername, serverAddress string) (message string, _ error) {
// Some links documenting this:
// - https://code.google.com/archive/p/mintty/issues/56
// - https://github.com/docker/docker/issues/15272
@@ -240,13 +241,12 @@ func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, de
// If we're logging into the index server and the user didn't provide a username or password, use the device flow
if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" {
var err error
msg, err = loginWithDeviceCodeFlow(ctx, dockerCLI)
// if the error represents a failure to initiate the device-code flow,
// then we fallback to regular cli credentials login
if !errors.Is(err, manager.ErrDeviceLoginStartFail) {
msg, err := loginWithDeviceCodeFlow(ctx, dockerCLI)
if err == nil || !errors.Is(err, manager.ErrDeviceLoginStartFail) {
return msg, err
}
logrus.WithError(err).Debug("failed to start web-based login")
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/registryauth"
cliopts "github.com/docker/cli/opts"
"github.com/moby/moby/api/types/swarm"
"github.com/moby/moby/client"
@@ -120,7 +121,7 @@ func runCreate(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet,
if opts.registryAuth {
// Retrieve encoded auth token from the image reference
var err error
encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), opts.image)
encodedAuth, err = registryauth.EncodedAuth(dockerCLI, opts.image)
if err != nil {
return err
}
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/internal/registryauth"
"github.com/docker/cli/opts"
"github.com/docker/cli/opts/swarmopts"
"github.com/moby/moby/api/types/container"
@@ -223,7 +224,7 @@ func runUpdate(ctx context.Context, dockerCLI command.Cli, flags *pflag.FlagSet,
// Retrieve encoded auth token from the image reference
// This would be the old image if it didn't change in this update
var err error
encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), spec.TaskTemplate.ContainerSpec.Image)
encodedAuth, err = registryauth.EncodedAuth(dockerCLI, spec.TaskTemplate.ContainerSpec.Image)
if err != nil {
return err
}
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/docker/cli/cli/command/service"
"github.com/docker/cli/cli/compose/convert"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/cli/internal/registryauth"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/swarm"
@@ -224,7 +225,7 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str
if sendAuth {
// Retrieve encoded auth token from the image reference
encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), image)
encodedAuth, err = registryauth.EncodedAuth(dockerCLI, image)
if err != nil {
return nil, err
}
+36 -4
View File
@@ -11,8 +11,9 @@ import (
"github.com/distribution/reference"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/jsonstream"
"github.com/moby/moby/api/types/jsonstream"
registrytypes "github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client/pkg/jsonmessage"
"github.com/opencontainers/go-digest"
"github.com/theupdateframework/notary/client"
"github.com/theupdateframework/notary/tuf/data"
@@ -46,7 +47,7 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo
// Count the times of calling for handleTarget,
// if it is called more that once, that should be considered an error in a trusted push.
cnt := 0
handleTarget := func(msg jsonstream.JSONMessage) {
handleTarget := func(msg jsonstream.Message) {
cnt++
if cnt > 1 {
// handleTarget should only be called once. This will be treated as an error.
@@ -78,14 +79,14 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo
default:
// We want trust signatures to always take an explicit tag,
// otherwise it will act as an untrusted push.
if err := jsonstream.Display(ctx, in, ioStreams.Out()); err != nil {
if err := displayJSONMessages(ctx, in, ioStreams.Out(), nil); err != nil {
return err
}
_, _ = fmt.Fprintln(ioStreams.Err(), "No tag specified, skipping trust metadata push")
return nil
}
if err := jsonstream.Display(ctx, in, ioStreams.Out(), jsonstream.WithAuxCallback(handleTarget)); err != nil {
if err := displayJSONMessages(ctx, in, ioStreams.Out(), handleTarget); err != nil {
return err
}
@@ -147,3 +148,34 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo
_, _ = fmt.Fprintf(ioStreams.Out(), "Successfully signed %s:%s\n", repoInfo.Name.Name(), tag)
return nil
}
type ctxReader struct {
err chan error
r io.Reader
}
func (r *ctxReader) Read(p []byte) (int, error) {
select {
case err := <-r.err:
return 0, err
default:
return r.r.Read(p)
}
}
// displayJSONMessages prints the JSON messages from in to out. It returns
// ctx.Err() if ctx is canceled, but not when in is closed.
func displayJSONMessages(ctx context.Context, in io.Reader, out *streams.Out, auxCallback func(jsonstream.Message)) error {
if ctx.Err() != nil {
return ctx.Err()
}
reader := &ctxReader{err: make(chan error, 1), r: in}
stop := context.AfterFunc(ctx, func() { reader.err <- ctx.Err() })
defer stop()
if err := jsonmessage.DisplayJSONMessagesStream(reader, out, out.FD(), out.IsTerminal(), auxCallback); err != nil {
return err
}
return ctx.Err()
}
-32
View File
@@ -1,32 +0,0 @@
package trust
import (
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/commands"
"github.com/spf13/cobra"
)
func init() {
commands.Register(newTrustCommand)
}
// newTrustCommand returns a cobra command for `trust` subcommands.
func newTrustCommand(dockerCLI command.Cli) *cobra.Command {
cmd := &cobra.Command{
Use: "trust",
Short: "Manage trust on Docker images",
Args: cli.NoArgs,
RunE: command.ShowHelp(dockerCLI.Err()),
DisableFlagsInUseLine: true,
}
cmd.AddCommand(
newRevokeCommand(dockerCLI),
newSignCommand(dockerCLI),
newTrustKeyCommand(dockerCLI),
newTrustSignerCommand(dockerCLI),
newInspectCommand(dockerCLI),
)
return cmd
}
+2 -2
View File
@@ -5,12 +5,12 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/docker/cli/internal/lazyregexp"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary"
"github.com/theupdateframework/notary/trustmanager"
@@ -41,7 +41,7 @@ func newKeyGenerateCommand(dockerCLI command.Streams) *cobra.Command {
}
// key names can use lowercase alphanumeric + _ + - characters
var validKeyName = lazyregexp.New(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
var validKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
// validate that all of the key names are unique and are alphanumeric + _ + -
// and that we do not already have public key files in the target dir on disk
+2 -2
View File
@@ -7,12 +7,12 @@ import (
"io"
"os"
"path"
"regexp"
"strings"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cmd/docker-trust/internal/trust"
"github.com/docker/cli/internal/lazyregexp"
"github.com/docker/cli/opts"
"github.com/spf13/cobra"
"github.com/theupdateframework/notary/client"
@@ -45,7 +45,7 @@ func newSignerAddCommand(dockerCLI command.Cli) *cobra.Command {
return cmd
}
var validSignerName = lazyregexp.New(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
var validSignerName = regexp.MustCompile(`^[a-z0-9][a-z0-9\_\-]*$`).MatchString
func addSigner(ctx context.Context, dockerCLI command.Cli, options signerAddOptions) error {
signerName := options.signer
+9
View File
@@ -580,6 +580,15 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
}
// Other missing or invalid plugins fall through to cmd.ExecuteContext(),
// which reports the standard unknown-command error.
//
// Cobra handles "--version" before it checks for unknown commands,
// so unset it to make "docker nonexistent --version" fail instead
// of printing the version. A global "--version" flag (as in
// "docker --version nonexistent") was already parsed, and still
// prints the version.
if !cmd.Flags().Changed("version") {
cmd.Version = ""
}
}
}
+15 -4
View File
@@ -70,6 +70,18 @@ With Docker Desktop, the credential store is already installed and configured
for you. Unless you want to change the credential store used by Docker Desktop,
you can skip the following steps.
#### Docker Desktop sign-in
When Docker Desktop is running and you are signed in, commands that pull or
push images through the Docker Engine, such as `docker pull`, `docker push`,
`docker run`, and `docker service create`, use the Docker Desktop session for
Docker Hub (`docker.io`) and Docker Hardened Images (`dhi.io`). The session is
used before the credential store, credential helpers, and the configuration
file. Credentials set in the `DOCKER_AUTH_CONFIG` environment variable take
precedence over the Docker Desktop session. If Docker Desktop is not installed
or not running, or you are not signed in, the Docker CLI uses its own
credentials.
#### Configure the credential store
You need to specify the credential store in `$HOME/.docker/config.json`
@@ -180,13 +192,12 @@ code, and authenticate.
```console
$ docker login
USING WEB-BASED LOGIN
To sign in with credentials on the command line, use 'docker login -u <username>'
Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'
Your one-time device confirmation code is: LNFR-PGCJ
Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate
Open this URL to authenticate: https://login.docker.com/activate?user_code=LNFR-PGCJ
Waiting for authentication in the browser…
Press ENTER to open the URL in your browser, or Ctrl+C to cancel.
```
After entering the code in your browser, you are authenticated to Docker Hub
+16
View File
@@ -62,6 +62,22 @@ func TestNonexistingHelp(t *testing.T) {
})
}
// TestNonexistingVersion ensures correct behaviour when invoking a
// nonexistent plugin with `--version`.
func TestNonexistingVersion(t *testing.T) {
run, _, cleanup := prepare(t)
defer cleanup()
for _, flag := range []string{"--version", "-v"} {
res := icmd.RunCmd(run("nonexistent", flag))
res.Assert(t, icmd.Expected{
ExitCode: 1,
Out: icmd.None,
})
golden.Assert(t, res.Stderr(), "docker-nonexistent-err.golden")
}
}
// TestRunBad ensures correct behaviour when running an existent but invalid plugin
func TestRunBad(t *testing.T) {
run, _, cleanup := prepare(t)
+1 -1
View File
@@ -30,5 +30,5 @@ func TestOauthLogin(t *testing.T) {
assert.NilError(t, err)
output, _ := io.ReadAll(p)
assert.Check(t, strings.Contains(string(output), "USING WEB-BASED LOGIN"), string(output))
assert.Check(t, strings.Contains(string(output), "Using web-based login"), string(output))
}
+27 -14
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/docker/cli/cli/config/credentials"
@@ -17,8 +18,6 @@ import (
"github.com/docker/cli/internal/registry"
"github.com/docker/cli/internal/tui"
"github.com/morikuni/aec"
"github.com/sirupsen/logrus"
"github.com/pkg/browser"
)
@@ -83,19 +82,18 @@ var ErrDeviceLoginStartFail = errors.New("failed to start device code flow login
// The retrieved tokens are stored in the credentials store (under a separate
// key), and the refresh token is concatenated with the client ID.
func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.AuthConfig, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
state, err := m.api.GetDeviceCode(ctx, m.audience)
if err != nil {
logrus.Debugf("failed to start device code login: %v", err)
return nil, ErrDeviceLoginStartFail
return nil, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err)
}
if state.UserCode == "" {
logrus.Debugf("failed to start device code login: missing user code")
return nil, ErrDeviceLoginStartFail
return nil, fmt.Errorf("%w: missing user code", ErrDeviceLoginStartFail)
}
_, _ = fmt.Fprintln(w, aec.Bold.Apply("\nUSING WEB-BASED LOGIN"))
var out tui.Output
switch stream := w.(type) {
case *streams.Out:
@@ -103,9 +101,10 @@ func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.Aut
default:
out = tui.NewOutput(streams.NewOut(w))
}
out.PrintNote("To sign in with credentials on the command line, use 'docker login -u <username>'\n")
_, _ = fmt.Fprintf(w, "\nYour one-time device confirmation code is: "+aec.Bold.Apply("%s\n"), state.UserCode)
_, _ = fmt.Fprintf(w, aec.Bold.Apply("Press ENTER")+" to open your browser or submit your device code here: "+aec.Underline.Apply("%s\n"), strings.Split(state.VerificationURI, "?")[0])
out.PrintNote("Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'\n")
_, _ = fmt.Fprintln(w, "Your one-time device confirmation code is:", aec.Bold.Apply(state.UserCode))
_, _ = fmt.Fprintln(w, "Open this URL to authenticate:", aec.Underline.Apply(state.VerificationURI))
_, _ = fmt.Fprintln(w, "\n"+aec.Bold.Apply("Press ENTER")+" to open the URL in your browser, or "+aec.Bold.Apply("Ctrl+C")+" to cancel.")
tokenResChan := make(chan api.TokenResponse)
waitForTokenErrChan := make(chan error)
@@ -120,11 +119,25 @@ func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.Aut
go func() {
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
_ = m.openBrowser(state.VerificationURI)
if _, err := reader.ReadString('\n'); err != nil || ctx.Err() != nil {
return
}
err := m.openBrowser(state.VerificationURI)
if ctx.Err() != nil {
return
}
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
_, _ = fmt.Fprintln(w, "No browser found. Open the URL above manually to continue.")
} else {
_, _ = fmt.Fprintln(w, "Failed to open browser:", err)
_, _ = fmt.Fprintln(w, "Open the URL above manually to continue.")
}
return
}
_, _ = fmt.Fprintln(w, "\nOpening browser…")
}()
_, _ = fmt.Fprint(w, "\nWaiting for authentication in the browser…\n")
var tokenRes api.TokenResponse
select {
case <-ctx.Done():
+3 -2
View File
@@ -51,7 +51,8 @@ const (
// IndexServer is used for user auth and image search
IndexServer = "https://index.docker.io/v1/"
// IndexName is the name of the index
IndexName = "docker.io"
IndexName = "docker.io"
DefaultRegistryHost = "registry-1.docker.io"
)
var (
@@ -61,7 +62,7 @@ var (
// or "ubuntu:latest"), as well as references using the "docker.io" domain
// name, which is used as canonical reference for images on Docker Hub, but
// does not match the domain-name of Docker Hub's registry.
DefaultV2Registry = &url.URL{Scheme: "https", Host: "registry-1.docker.io"}
DefaultV2Registry = &url.URL{Scheme: "https", Host: DefaultRegistryHost}
validHostPortRegex = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^` + reference.DomainRegexp.String() + `$`)
+62
View File
@@ -0,0 +1,62 @@
package registryauth
import (
"os"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config/configfile"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/internal/secretsengine"
"github.com/moby/moby/api/pkg/authconfig"
registrytypes "github.com/moby/moby/api/types/registry"
)
type configProvider interface {
ConfigFile() *configfile.ConfigFile
}
type authProvider interface {
RegistryAuthProvider() secretsengine.AuthProvider
}
type envAuthFunc func(registryHostname string) (configtypes.AuthConfig, bool)
func EncodedAuth(dockerCLI configProvider, image string) (string, error) {
var auth secretsengine.AuthProvider = dockerCLI.ConfigFile()
if p, ok := dockerCLI.(authProvider); ok {
auth = p.RegistryAuthProvider()
}
return encodedAuth(envAuthConfig, auth, image)
}
func encodedAuth(envAuth envAuthFunc, auth secretsengine.AuthProvider, image string) (string, error) {
registryRef, err := reference.ParseNormalizedNamed(image)
if err != nil {
return "", err
}
domain := reference.Domain(registryRef)
authConfig, ok := envAuth(domain)
if !ok {
authConfig, err = auth.GetAuthConfig(domain)
if err != nil {
return "", err
}
}
return authconfig.Encode(registrytypes.AuthConfig{
Username: authConfig.Username,
Password: authConfig.Password,
ServerAddress: authConfig.ServerAddress,
Auth: authConfig.Auth,
IdentityToken: authConfig.IdentityToken,
RegistryToken: authConfig.RegistryToken,
})
}
func envAuthConfig(registryHostname string) (configtypes.AuthConfig, bool) {
if os.Getenv(configfile.DockerEnvConfigKey) == "" {
return configtypes.AuthConfig{}, false
}
authConfig, err := configfile.New("").GetAuthConfig(registryHostname)
return authConfig, err == nil && authConfig.Username != ""
}
+131
View File
@@ -0,0 +1,131 @@
package registryauth
import (
"bytes"
"sync"
"testing"
"github.com/docker/cli/cli/config/configfile"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/internal/secretsengine"
"github.com/moby/moby/api/pkg/authconfig"
registrytypes "github.com/moby/moby/api/types/registry"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
type recordingAuthProvider struct {
mu sync.Mutex
hosts []string
}
func (p *recordingAuthProvider) GetAuthConfig(registryHostname string) (configtypes.AuthConfig, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.hosts = append(p.hosts, registryHostname)
return configtypes.AuthConfig{Username: "hubuser", Password: "desktop-token", ServerAddress: registryHostname}, nil
}
type configOnlyCli struct {
cfg *configfile.ConfigFile
}
func (c *configOnlyCli) ConfigFile() *configfile.ConfigFile {
return c.cfg
}
type fakeCli struct {
configOnlyCli
auth *recordingAuthProvider
}
func (c *fakeCli) RegistryAuthProvider() secretsengine.AuthProvider {
return c.auth
}
func noEnvAuth(string) (configtypes.AuthConfig, bool) {
return configtypes.AuthConfig{}, false
}
func loadConfig(t *testing.T) *configfile.ConfigFile {
t.Helper()
const configFileContent = `{"auths": {
"https://index.docker.io/v1/": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="},
"localhost:5000": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}
}
}`
cfg := &configfile.ConfigFile{}
assert.NilError(t, cfg.LoadFromReader(bytes.NewReader([]byte(configFileContent))))
return cfg
}
func TestEncodedAuthUsesCLIAuthProvider(t *testing.T) {
t.Parallel()
dockerCLI := &fakeCli{configOnlyCli: configOnlyCli{cfg: configfile.New("")}, auth: &recordingAuthProvider{}}
actual, err := EncodedAuth(dockerCLI, "dhi.io/python")
assert.NilError(t, err)
expected, err := authconfig.Encode(registrytypes.AuthConfig{Username: "hubuser", Password: "desktop-token", ServerAddress: "dhi.io"})
assert.NilError(t, err)
assert.Check(t, is.Equal(actual, expected))
assert.Check(t, is.DeepEqual(dockerCLI.auth.hosts, []string{"dhi.io"}))
}
func TestEncodedAuthWithoutAuthProviderUsesConfigFile(t *testing.T) {
t.Parallel()
actual, err := EncodedAuth(&configOnlyCli{cfg: loadConfig(t)}, "ubuntu")
assert.NilError(t, err)
expected, err := authconfig.Encode(registrytypes.AuthConfig{Username: "username", Password: "password", ServerAddress: "https://index.docker.io/v1/"})
assert.NilError(t, err)
assert.Check(t, is.Equal(actual, expected))
}
func TestEncodedAuthEnvTakesPrecedence(t *testing.T) {
t.Parallel()
envAuth := func(registryHostname string) (configtypes.AuthConfig, bool) {
return configtypes.AuthConfig{Username: "envuser", Password: "envpass", ServerAddress: registryHostname}, true
}
auth := &recordingAuthProvider{}
actual, err := encodedAuth(envAuth, auth, "ubuntu")
assert.NilError(t, err)
expected, err := authconfig.Encode(registrytypes.AuthConfig{Username: "envuser", Password: "envpass", ServerAddress: "docker.io"})
assert.NilError(t, err)
assert.Check(t, is.Equal(actual, expected))
assert.Check(t, is.Len(auth.hosts, 0), "the auth provider must not be asked for a registry in DOCKER_AUTH_CONFIG")
}
func TestEnvAuthConfig(t *testing.T) {
t.Setenv(configfile.DockerEnvConfigKey, `{"auths":{"https://index.docker.io/v1/":{"auth":"ZW52dXNlcjplbnZwYXNz"}}}`)
got, ok := envAuthConfig("docker.io")
assert.Check(t, ok)
assert.Check(t, is.DeepEqual(got, configtypes.AuthConfig{Username: "envuser", Password: "envpass", ServerAddress: "https://index.docker.io/v1/"}))
_, ok = envAuthConfig("ghcr.io")
assert.Check(t, !ok)
}
func TestEncodedAuthFromConfigFile(t *testing.T) {
t.Parallel()
cfg := loadConfig(t)
tests := []struct {
image string
expected registrytypes.AuthConfig
}{
{image: "ubuntu", expected: registrytypes.AuthConfig{Username: "username", Password: "password", ServerAddress: "https://index.docker.io/v1/"}},
{image: "localhost:5000/ubuntu", expected: registrytypes.AuthConfig{Username: "username", Password: "password", ServerAddress: "localhost:5000"}},
{image: "no-auth.example.com/ubuntu", expected: registrytypes.AuthConfig{}},
}
for _, tc := range tests {
t.Run(tc.image, func(t *testing.T) {
t.Parallel()
actual, err := encodedAuth(noEnvAuth, cfg, tc.image)
assert.NilError(t, err)
expected, err := authconfig.Encode(tc.expected)
assert.NilError(t, err)
assert.Check(t, is.Equal(actual, expected))
})
}
}
+199
View File
@@ -0,0 +1,199 @@
//go:build go1.26
package secretsengine
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sync"
"time"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/internal/registry"
seclient "github.com/docker/secrets-engine/client"
"github.com/docker/secrets-engine/client/dockerhub"
"github.com/docker/secrets-engine/x/api"
"github.com/sirupsen/logrus"
)
const (
lookupTimeout = 5 * time.Second
sessionExpirySkew = 30 * time.Second
sessionTTL = time.Minute
retryInterval = time.Minute
stagingRegistryHost = "registry-1-stage.docker.io"
dhiRegistryHost = "dhi.io"
)
var errSessionExpired = errors.New("the session has expired (not signed in to Docker Desktop?)")
type hubClient interface {
GetDefaultSession(ctx context.Context) (dockerhub.UserSession, error)
GetDefaultProfile(ctx context.Context) (dockerhub.Profile, error)
}
var _ hubClient = dockerhub.ClientAuth(nil)
type AuthProvider interface {
GetAuthConfig(registryHostname string) (types.AuthConfig, error)
}
type provider struct {
fallback AuthProvider
hub *session
staging *session
}
func NewDesktop(fallback AuthProvider) AuthProvider {
return &provider{
fallback: fallback,
hub: newDesktopSession("Docker Hub"),
staging: newDesktopSession("Docker Hub staging", dockerhub.Staging()),
}
}
func (p *provider) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
if s, serverAddress := p.sessionFor(registryHostname); s != nil {
if auth, ok := s.auth(); ok {
auth.ServerAddress = serverAddress
return auth, nil
}
}
return p.fallback.GetAuthConfig(registryHostname)
}
func (p *provider) sessionFor(registryHostname string) (*session, string) {
switch credentials.ConvertToHostname(registryHostname) {
case registry.IndexName, registry.IndexHostname, registry.DefaultRegistryHost:
return p.hub, registry.IndexServer
case dhiRegistryHost:
return p.hub, dhiRegistryHost
case stagingRegistryHost:
return p.staging, stagingRegistryHost
default:
return nil, ""
}
}
type session struct {
name string
connect func() (hubClient, error)
mu sync.Mutex
hub hubClient
retryAt time.Time
cached *types.AuthConfig
validUntil time.Time
}
func newDesktopSession(name string, opts ...dockerhub.Option) *session {
return &session{
name: name,
connect: func() (hubClient, error) {
return connect(api.DesktopSocketPath(), opts...)
},
}
}
func connect(socketPath string, opts ...dockerhub.Option) (hubClient, error) {
if _, err := os.Lstat(socketPath); errors.Is(err, fs.ErrNotExist) {
return nil, err
}
c, err := seclient.New(
seclient.WithSocketPath(socketPath),
seclient.WithTimeout(lookupTimeout),
)
if err != nil {
return nil, err
}
return c.HubAuth(opts...), nil
}
func (s *session) auth() (types.AuthConfig, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if now.Before(s.retryAt) {
return types.AuthConfig{}, false
}
if s.cached != nil && now.Before(s.validUntil) {
return *s.cached, true
}
auth, validUntil, err := s.fetch()
if err != nil {
s.retryAt = time.Now().Add(retryInterval)
s.cached = nil
logFallback(s.name, err)
return types.AuthConfig{}, false
}
logrus.Debugf("using the %s session of %q from Docker Desktop", s.name, auth.Username)
s.cached, s.validUntil = &auth, validUntil
return auth, true
}
func (s *session) fetch() (types.AuthConfig, time.Time, error) {
if s.hub == nil {
hub, err := s.connect()
if err != nil {
return types.AuthConfig{}, time.Time{}, err
}
s.hub = hub
}
ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout)
defer cancel()
userSession, err := s.hub.GetDefaultSession(ctx)
if err != nil {
return types.AuthConfig{}, time.Time{}, err
}
username := userSession.Claims.Username
if username == "" {
profile, err := s.hub.GetDefaultProfile(ctx)
if err != nil {
return types.AuthConfig{}, time.Time{}, fmt.Errorf("resolving the Docker Hub username: %w", err)
}
username = profile.Username
}
if username == "" {
return types.AuthConfig{}, time.Time{}, errors.New("no username for the session")
}
now := time.Now()
validUntil := now.Add(sessionTTL)
if exp := userSession.Claims.ExpiresAt; exp != nil {
validUntil = exp.Add(-sessionExpirySkew)
if !now.Before(validUntil) {
return types.AuthConfig{}, time.Time{}, errSessionExpired
}
}
return types.AuthConfig{
Username: username,
Password: userSession.AccessToken,
}, validUntil, nil
}
func logFallback(name string, err error) {
if isSessionUnavailable(err) {
logrus.Debugf("no %s session available from Docker Desktop, using Docker CLI credentials: %v", name, err)
return
}
logrus.Warnf("could not use the %s session from Docker Desktop, using Docker CLI credentials instead: %v", name, err)
}
func isSessionUnavailable(err error) bool {
if _, ok := errors.AsType[*seclient.ConnectError](err); ok {
return true
}
return errors.Is(err, fs.ErrNotExist) ||
errors.Is(err, dockerhub.ErrNoSession) ||
errors.Is(err, errSessionExpired)
}
@@ -0,0 +1,429 @@
//go:build go1.26
package secretsengine
import (
"context"
"errors"
"fmt"
"io/fs"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/internal/registry"
seclient "github.com/docker/secrets-engine/client"
"github.com/docker/secrets-engine/client/dockerhub"
"gotest.tools/v3/assert"
)
type fakeHub struct {
session dockerhub.UserSession
sessionErr error
profile dockerhub.Profile
profileErr error
sessionCalls atomic.Int32
}
func (f *fakeHub) GetDefaultSession(context.Context) (dockerhub.UserSession, error) {
f.sessionCalls.Add(1)
return f.session, f.sessionErr
}
func (f *fakeHub) GetDefaultProfile(context.Context) (dockerhub.Profile, error) {
return f.profile, f.profileErr
}
func hubSession(username string, expiresAt time.Time) dockerhub.UserSession {
s := dockerhub.UserSession{AccessToken: "desktop-oauth-token"}
s.Claims.Username = username
if !expiresAt.IsZero() {
s.Claims.ExpiresAt = &dockerhub.NumericDate{Time: expiresAt}
}
return s
}
func inAnHour() time.Time {
return time.Now().Add(time.Hour)
}
type recordingFallback struct {
mu sync.Mutex
hosts []string
}
func (f *recordingFallback) GetAuthConfig(registryHostname string) (types.AuthConfig, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.hosts = append(f.hosts, registryHostname)
return cliCredentials(registryHostname), nil
}
func cliCredentials(registryHostname string) types.AuthConfig {
return types.AuthConfig{Username: "cli-user", Password: "cli-password", ServerAddress: registryHostname}
}
func desktopCredentials() types.AuthConfig {
return types.AuthConfig{Username: "hubuser", Password: "desktop-oauth-token", ServerAddress: registry.IndexServer}
}
func stagingCredentials() types.AuthConfig {
return types.AuthConfig{Username: "stageuser", Password: "staging-oauth-token", ServerAddress: stagingRegistryHost}
}
type testProvider struct {
*provider
fallback *recordingFallback
staging *fakeHub
}
func newTestProvider(hub hubClient) *testProvider {
tp := &testProvider{
fallback: &recordingFallback{},
staging: &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile},
}
tp.provider = &provider{
fallback: tp.fallback,
hub: &session{name: "Docker Hub", hub: hub},
staging: &session{name: "Docker Hub staging", hub: tp.staging},
}
return tp
}
func (tp *testProvider) signInToStaging() {
tp.staging.sessionErr = nil
tp.staging.session = hubSession("stageuser", inAnHour())
tp.staging.session.AccessToken = "staging-oauth-token"
}
func connectError(reason seclient.ConnectReason) error {
return &seclient.ConnectError{
Reason: reason,
SocketPath: "/run/engine.sock",
Err: errors.New("dial unix /run/engine.sock"),
}
}
func TestNewDesktopUsesSeparateSessions(t *testing.T) {
t.Parallel()
p := NewDesktop(&recordingFallback{}).(*provider)
assert.Assert(t, p.hub != p.staging, "production and staging sessions must be separate")
}
func TestDockerHubUsesDesktopSession(t *testing.T) {
t.Parallel()
for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io", registry.IndexServer} {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, got.IdentityToken, "", "an IdentityToken is sent as a refresh token")
assert.Equal(t, len(p.fallback.hosts), 0)
})
}
}
func TestOtherRegistriesUseFallback(t *testing.T) {
t.Parallel()
hosts := []string{
"ghcr.io", "localhost:5000", "registry.example.com", "",
stagingRegistryHost + ".example.com", "example.com/" + stagingRegistryHost,
dhiRegistryHost + ".example.com", "example.com/" + dhiRegistryHost, "registry." + dhiRegistryHost,
"docker.io.example.com",
}
for _, host := range hosts {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
p.signInToStaging()
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials(host))
assert.DeepEqual(t, p.fallback.hosts, []string{host})
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "Docker Desktop must only be contacted for Docker Hub")
})
}
}
func TestDHIUsesDockerHubSession(t *testing.T) {
t.Parallel()
for _, host := range []string{dhiRegistryHost, "https://" + dhiRegistryHost, "https://" + dhiRegistryHost + "/"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
p.signInToStaging()
want := desktopCredentials()
want.ServerAddress = dhiRegistryHost
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, want)
assert.Equal(t, p.staging.sessionCalls.Load(), int32(0), "the staging session must not be read for DHI")
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
})
}
}
func TestStagingRegistryUsesStagingSession(t *testing.T) {
t.Parallel()
for _, host := range []string{stagingRegistryHost, "https://" + stagingRegistryHost + "/"} {
t.Run(host, func(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
p.signInToStaging()
got, err := p.GetAuthConfig(host)
assert.NilError(t, err)
assert.DeepEqual(t, got, stagingCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(0), "the production session must not be read for the staging registry")
})
}
}
func TestEnvironmentsDoNotMix(t *testing.T) {
t.Parallel()
t.Run("staging session is not used for docker.io", func(t *testing.T) {
t.Parallel()
p := newTestProvider(&fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile})
p.signInToStaging()
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
got, err = p.GetAuthConfig(stagingRegistryHost)
assert.NilError(t, err)
assert.DeepEqual(t, got, stagingCredentials())
})
t.Run("production session is not used for the staging registry", func(t *testing.T) {
t.Parallel()
p := newTestProvider(&fakeHub{session: hubSession("hubuser", inAnHour())})
got, err := p.GetAuthConfig(stagingRegistryHost)
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials(stagingRegistryHost))
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
})
}
func TestUsernameFromProfile(t *testing.T) {
t.Parallel()
hub := &fakeHub{
session: hubSession("", inAnHour()),
profile: dockerhub.Profile{Username: "hubuser"},
}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
}
func TestFallsBackWithoutUsableSession(t *testing.T) {
t.Parallel()
tests := []struct {
name string
hub func() *fakeHub
}{
{name: "secrets engine not running", hub: func() *fakeHub { return &fakeHub{sessionErr: connectError(seclient.ReasonNotRunning)} }},
{name: "secrets engine socket not accessible", hub: func() *fakeHub { return &fakeHub{sessionErr: connectError(seclient.ReasonPermissionDenied)} }},
{name: "nobody signed in", hub: func() *fakeHub { return &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile} }},
{name: "access denied", hub: func() *fakeHub { return &fakeHub{sessionErr: seclient.ErrAccessDenied} }},
{name: "no username", hub: func() *fakeHub { return &fakeHub{session: hubSession("", inAnHour())} }},
{name: "profile lookup fails", hub: func() *fakeHub {
return &fakeHub{session: hubSession("", inAnHour()), profileErr: errors.New("boom")}
}},
{name: "expired", hub: func() *fakeHub { return &fakeHub{session: hubSession("hubuser", time.Now().Add(-time.Minute))} }},
{name: "expires within the skew", hub: func() *fakeHub {
return &fakeHub{session: hubSession("hubuser", time.Now().Add(sessionExpirySkew/2))}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
hub := tc.hub()
p := newTestProvider(hub)
for range 2 {
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
}
assert.Equal(t, hub.sessionCalls.Load(), int32(1), "Desktop must not be asked again within retryInterval")
})
})
}
}
func TestDesktopFallsBackWhenClientCannotBeCreated(t *testing.T) {
t.Parallel()
var connects atomic.Int32
s := &session{
name: "Docker Hub",
connect: func() (hubClient, error) {
connects.Add(1)
return nil, errors.New("no socket path")
},
}
p := &provider{fallback: &recordingFallback{}, hub: s}
for range 2 {
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
}
assert.Equal(t, connects.Load(), int32(1))
}
func TestRetriesAfterInterval(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
hub := &fakeHub{sessionErr: dockerhub.ErrNoDefaultProfile}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
hub.sessionErr = nil
hub.session = hubSession("hubuser", inAnHour())
time.Sleep(retryInterval - time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, cliCredentials("docker.io"))
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
time.Sleep(time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
})
}
func TestReusesSessionUntilExpiry(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
expiresAt := time.Now().Add(10 * time.Minute)
hub := &fakeHub{session: hubSession("hubuser", expiresAt)}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
time.Sleep(5 * time.Minute)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
hub.session = hubSession("hubuser", expiresAt.Add(time.Hour))
hub.session.AccessToken = "refreshed-token"
time.Sleep(5*time.Minute - sessionExpirySkew)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.Equal(t, got.Password, "refreshed-token")
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
})
}
func TestRefetchesSessionWithoutExpiry(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
hub := &fakeHub{session: hubSession("hubuser", time.Time{})}
p := newTestProvider(hub)
got, err := p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
time.Sleep(sessionTTL - time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
time.Sleep(time.Second)
got, err = p.GetAuthConfig("docker.io")
assert.NilError(t, err)
assert.DeepEqual(t, got, desktopCredentials())
assert.Equal(t, hub.sessionCalls.Load(), int32(2))
})
}
func TestConcurrentLookupsShareOneFetch(t *testing.T) {
t.Parallel()
hub := &fakeHub{session: hubSession("hubuser", inAnHour())}
p := newTestProvider(hub)
const lookups = 20
results := make([]types.AuthConfig, lookups)
var wg sync.WaitGroup
for i := range lookups {
wg.Go(func() {
auth, err := p.GetAuthConfig("docker.io")
assert.Check(t, err)
results[i] = auth
})
}
wg.Wait()
for _, auth := range results {
assert.DeepEqual(t, auth, desktopCredentials())
}
assert.Equal(t, hub.sessionCalls.Load(), int32(1))
}
func TestConnectSkipsMissingSocket(t *testing.T) {
t.Parallel()
_, err := connect(filepath.Join(t.TempDir(), "engine.sock"))
assert.Assert(t, errors.Is(err, fs.ErrNotExist), err)
assert.Assert(t, isSessionUnavailable(err))
}
func TestIsSessionUnavailable(t *testing.T) {
t.Parallel()
for _, err := range []error{
connectError(seclient.ReasonNotRunning),
fmt.Errorf("get session: %w", connectError(seclient.ReasonNotRunning)),
connectError(seclient.ReasonPermissionDenied),
connectError(seclient.ReasonTimeout),
connectError(seclient.ReasonUnknown),
dockerhub.ErrNoSession,
dockerhub.ErrNoDefaultProfile,
errSessionExpired,
fs.ErrNotExist,
} {
assert.Assert(t, isSessionUnavailable(err), err)
}
for _, err := range []error{
seclient.ErrAccessDenied,
errors.New("no username for the session"),
context.DeadlineExceeded,
} {
assert.Assert(t, !isSessionUnavailable(err), err)
}
}
+5
View File
@@ -12,6 +12,7 @@ import (
manifeststore "github.com/docker/cli/cli/manifest/store"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/registryclient"
"github.com/docker/cli/internal/secretsengine"
"github.com/moby/moby/client"
)
@@ -176,6 +177,10 @@ func (c *FakeCli) SetRegistryClient(registryClient registryclient.RegistryClient
c.registryClient = registryClient
}
func (c *FakeCli) RegistryAuthProvider() secretsengine.AuthProvider {
return c.configfile
}
// BuildKitEnabled on the fake cli
func (*FakeCli) BuildKitEnabled() (bool, error) {
return true, nil
+14 -6
View File
@@ -83,16 +83,24 @@ set as the **URL**, the repository is cloned locally and then sent as the contex
Add a line to /etc/hosts. The format is hostname=ip, or hostname:ip.
The **--add-host** option can be set multiple times.
**--build-arg** *variable*
name and value of a **buildarg**.
**--build-arg** *name*[=*value*]
Set a build-time variable. The *name* is the variable name, as declared
by an `ARG` instruction in the Dockerfile. If *value* is omitted, the
value of the variable *name* from the local environment is used; if it is
not set there either, the default from the `ARG` instruction (if any) is
used.
Build-time variables are available as environment variables to commands
run by the Dockerfile's `RUN` instructions, and can be used for variable
expansion in other Dockerfile instructions. An environment variable defined
by an `ENV` instruction always overrides an `ARG` of the same name. This
mechanism is not meant for passing secret values.
For example, if you want to pass a value for `http_proxy`, use
`--build-arg=http_proxy="http://some.proxy.url"`
Users pass these values at build-time. Docker uses the `buildargs` as the
environment context for command(s) run via the Dockerfile's `RUN` instruction
or for variable expansion in other Dockerfile instructions. This is not meant
for passing secret values. [Read more about the buildargs instruction](https://docs.docker.com/engine/reference/builder/#arg)
See also https://docs.docker.com/reference/dockerfile/#arg and
https://docs.docker.com/reference/cli/docker/buildx/build/#build-arg.
**--cache-from** ""
Set image that will be used as a build cache source.
+5 -2
View File
@@ -4,7 +4,7 @@ module github.com/docker/cli
// There is no 'go.mod' file, as that would imply opting in for all the rules
// around SemVer, which this repo cannot abide by as it uses CalVer.
go 1.26.0
go 1.26.10
tool (
github.com/cpuguy83/go-md2man/v2 // for scripts/docs/generate-man.sh
@@ -24,7 +24,9 @@ require (
github.com/docker/docker-credential-helpers v0.9.9
github.com/docker/go-connections v0.8.2
github.com/docker/go-units v0.5.0
github.com/fvbommel/sortorder v1.2.0
github.com/docker/secrets-engine/client v0.2.2
github.com/docker/secrets-engine/x v0.8.5
github.com/fvbommel/sortorder v1.2.1
github.com/go-jose/go-jose/v4 v4.1.5
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gogo/protobuf v1.3.2
@@ -70,6 +72,7 @@ require (
)
require (
connectrpc.com/connect v1.19.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect
github.com/Microsoft/go-winio v0.6.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
+8 -2
View File
@@ -1,3 +1,5 @@
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -47,10 +49,14 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
github.com/docker/secrets-engine/client v0.2.2 h1:aPvmlzsxNA+C+xgp2F+WiiDbCSAXHC591HscFFOvPOE=
github.com/docker/secrets-engine/client v0.2.2/go.mod h1:c3l/N+fA0kiy9S8BE3kKrO5hOZu4+SLYZK9Jx+jc2cw=
github.com/docker/secrets-engine/x v0.8.5 h1:P7D/YESMjpX18S4ratBgoeJkAFIxgsPxe093uFmgiGc=
github.com/docker/secrets-engine/x v0.8.5/go.mod h1:jQ5CXCYCPn8Sw96TS8Ufq3oyIGH2GzUtVXIdwa2Q0VU=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fvbommel/sortorder v1.2.0 h1:TRIiRiGX+djh3Yf4FVxmWmAcYfIr5dH0NbzJWOSAWZk=
github.com/fvbommel/sortorder v1.2.0/go.mod h1:LbhO04ijZIeUuvz9B9BkI/qYrpZZEn1gWhxv4QjUKVs=
github.com/fvbommel/sortorder v1.2.1 h1:ASc85RbthwLEqzuiCk7WQQiopfyGWQiEYZVJ3Qa4A+U=
github.com/fvbommel/sortorder v1.2.1/go.mod h1:LbhO04ijZIeUuvz9B9BkI/qYrpZZEn1gWhxv4QjUKVs=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+5
View File
@@ -0,0 +1,5 @@
/.tmp/
*.pprof
*.svg
cover.out
connect.test
+130
View File
@@ -0,0 +1,130 @@
linters-settings:
errcheck:
check-type-assertions: true
exhaustruct:
include:
# No zero values for param structs.
- 'connectrpc\.com/connect\..*[pP]arams'
forbidigo:
forbid:
- '^fmt\.Print'
- '^log\.'
- '^print$'
- '^println$'
- '^panic$'
godox:
# TODO, OPT, etc. comments are fine to commit. Use FIXME comments for
# temporary hacks, and use godox to prevent committing them.
keywords: [FIXME]
importas:
no-unaliased: true
alias:
- pkg: connectrpc.com/connect/internal/gen/connect/ping/v1
alias: pingv1
varnamelen:
ignore-decls:
- T any
- i int
- wg sync.WaitGroup
linters:
enable-all: true
disable:
- cyclop # covered by gocyclo
- depguard # unnecessary for small libraries
- funlen # rely on code review to limit function length
- gocognit # dubious "cognitive overhead" quantification
- gofumpt # prefer standard gofmt
- goimports # rely on gci instead
- inamedparam # convention is not followed
- ireturn # "accept interfaces, return structs" isn't ironclad
- lll # don't want hard limits for line length
- maintidx # covered by gocyclo
- mnd # status codes are clearer than constants
- nlreturn # generous whitespace violates house style
- nonamedreturns # named returns are fine; it's *bare* returns that are bad
- protogetter # too many false positives
- tenv # replaced by usetesting
- testpackage # internal tests are fine
- wrapcheck # don't _always_ need to wrap errors
- wsl # generous whitespace violates house style
issues:
exclude-dirs-use-default: false
exclude:
# Don't ban use of fmt.Errorf to create new errors, but the remaining
# checks from err113 are useful.
- "do not define dynamic errors, use wrapped static errors instead: .*"
exclude-rules:
# If future reflect.Kinds are nil-able, we'll find out when a test fails.
- linters: [exhaustive]
path: internal/assert/assert.go
# We need our duplex HTTP call to have access to the context.
- linters: [containedctx]
path: duplex_http_call.go
# We need to init a global in-mem HTTP server for testable examples.
- linters: [gochecknoinits, gochecknoglobals]
path: example_init_test.go
# We purposefully do an ineffectual assignment for an example.
- linters: [ineffassign]
path: client_example_test.go
# The generated file is effectively a global receiver.
- linters: [varnamelen]
path: cmd/protoc-gen-connect-go
text: "parameter name 'g' is too short"
# Thorough error logging and timeout config make this example unreadably long.
- linters: [errcheck, gosec]
path: error_writer_example_test.go
# It should be crystal clear that Connect uses plain *http.Clients.
- linters: [revive, stylecheck]
path: client_example_test.go
# Don't complain about timeout management or lack of output assertions in examples.
- linters: [gosec, testableexamples]
path: handler_example_test.go
# No output assertions needed for these examples.
- linters: [testableexamples]
path: error_writer_example_test.go
- linters: [testableexamples]
path: error_not_modified_example_test.go
- linters: [testableexamples]
path: error_example_test.go
# In examples, it's okay to use http.ListenAndServe.
- linters: [gosec]
path: error_not_modified_example_test.go
# There are many instances where we want to keep unused parameters
# as a matter of style or convention, for example when a context.Context
# is the first parameter, we choose to just globally ignore this.
- linters: [revive]
text: "^unused-parameter: "
# We want to return explicit nils in protocol_grpc.go
- linters: [revive]
text: "^if-return: "
path: protocol_grpc.go
# We want to return explicit nils in protocol_connect.go
- linters: [revive]
text: "^if-return: "
path: protocol_connect.go
# We want to return explicit nils in error_writer.go
- linters: [revive]
text: "^if-return: "
path: error_writer.go
# We want to set http.Server's logger
- linters: [forbidigo]
path: internal/memhttp
text: "use of `log.(New|Logger|Lshortfile)` forbidden by pattern .*"
# We want to show examples with http.Get
- linters: [noctx]
path: internal/memhttp/memhttp_test.go
# Allow fmt.Sprintf for cmd/protoc-gen-connect-go for consistency
- linters: [perfsprint]
path: cmd/protoc-gen-connect-go/main.go
# Allow non-canonical headers in tests
- linters: [canonicalheader]
path: '.*_test.go'
# Allow Code pointer receiver for UnmarshalText method
- linters: [recvcheck]
path: code.go
# Avoid false positives for int overflow in tests
- linters: [gosec]
text: "^G115: integer overflow conversion"
path: ".*_test.go"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021-2025 The Connect Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+12
View File
@@ -0,0 +1,12 @@
Maintainers
===========
## Current
* [Peter Edge](https://github.com/bufdev), [Buf](https://buf.build)
* [Josh Humphries](https://github.com/jhump), [Buf](https://buf.build)
* [Matt Robenolt](https://github.com/mattrobenolt), [PlanetScale](https://planetscale.com)
* [Edward McFarlane](https://github.com/emcfarlane), [Buf](https://buf.build)
## Former
* [Akshay Shah](https://github.com/akshayjshah)
* [Alex McKinney](https://github.com/amckinney)
+121
View File
@@ -0,0 +1,121 @@
# See https://tech.davis-hansson.com/p/make/
SHELL := bash
.DELETE_ON_ERROR:
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := all
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
MAKEFLAGS += --no-print-directory
BIN := .tmp/bin
export PATH := $(abspath $(BIN)):$(PATH)
export GOBIN := $(abspath $(BIN))
COPYRIGHT_YEARS := 2021-2025
LICENSE_IGNORE := --ignore /testdata/
BUF_VERSION := 1.50.1
.PHONY: help
help: ## Describe useful make targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}'
.PHONY: all
all: ## Build, test, and lint (default)
$(MAKE) test
$(MAKE) lint
.PHONY: clean
clean: ## Delete intermediate build artifacts
@# -X only removes untracked files, -d recurses into directories, -f actually removes files/dirs
git clean -Xdf
.PHONY: test
test: shorttest slowtest
.PHONY: shorttest
shorttest: build ## Run unit tests
go test -vet=off -race -cover -short ./...
.PHONY: slowtest
# Runs all tests, including known long/slow ones. The
# race detector is not used for a few reasons:
# 1. Race coverage of the short tests should be
# adequate to catch race conditions.
# 2. It slows tests down, which is not good if we
# know these are already slow tests.
# 3. Some of the slow tests can't repro issues and
# find regressions as reliably with the race
# detector enabled.
slowtest: build
go test ./...
.PHONY: runconformance
runconformance: build ## Run conformance test suite
cd internal/conformance && ./runconformance.sh
.PHONY: bench
bench: BENCH ?= .*
bench: build ## Run benchmarks for root package
go test -vet=off -run '^$$' -bench '$(BENCH)' -benchmem -cpuprofile cpu.pprof -memprofile mem.pprof .
.PHONY: build
build: generate ## Build all packages
go build ./...
.PHONY: install
install: ## Install all binaries
go install ./...
.PHONY: lint
lint: $(BIN)/golangci-lint $(BIN)/buf ## Lint Go and protobuf
go vet ./...
golangci-lint run --modules-download-mode=readonly --timeout=3m0s
buf lint
buf format -d --exit-code
.PHONY: lintfix
lintfix: $(BIN)/golangci-lint $(BIN)/buf ## Automatically fix some lint errors
golangci-lint run --fix --modules-download-mode=readonly --timeout=3m0s
buf format -w
.PHONY: generate
generate: $(BIN)/buf $(BIN)/protoc-gen-go $(BIN)/protoc-gen-connect-go $(BIN)/license-header ## Regenerate code and licenses
go mod tidy
cd ./internal/conformance && go mod tidy
buf generate
cd ./cmd/protoc-gen-connect-go/internal && \
find ./testdata -maxdepth 1 -type d \( ! -name testdata \) | xargs -n 1 -I % bash -c "cd '%' && buf generate"
license-header \
--license-type apache \
--copyright-holder "The Connect Authors" \
--year-range "$(COPYRIGHT_YEARS)" $(LICENSE_IGNORE)
.PHONY: upgrade
upgrade: ## Upgrade dependencies
go get -u -t ./... && go mod tidy -v
.PHONY: checkgenerate
checkgenerate:
@# Used in CI to verify that `make generate` doesn't produce a diff.
test -z "$$(git status --porcelain | tee /dev/stderr)"
.PHONY: $(BIN)/protoc-gen-connect-go
$(BIN)/protoc-gen-connect-go:
@mkdir -p $(@D)
go build -o $(@) ./cmd/protoc-gen-connect-go
$(BIN)/buf: Makefile
@mkdir -p $(@D)
go install github.com/bufbuild/buf/cmd/buf@v${BUF_VERSION}
$(BIN)/license-header: Makefile
@mkdir -p $(@D)
go install github.com/bufbuild/buf/private/pkg/licenseheader/cmd/license-header@v${BUF_VERSION}
$(BIN)/golangci-lint: Makefile
@mkdir -p $(@D)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.7
$(BIN)/protoc-gen-go: Makefile go.mod
@mkdir -p $(@D)
@# The version of protoc-gen-go is determined by the version in go.mod
go install google.golang.org/protobuf/cmd/protoc-gen-go
+184
View File
@@ -0,0 +1,184 @@
Connect
=======
[![Build](https://github.com/connectrpc/connect-go/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/connectrpc/connect-go/actions/workflows/ci.yaml)
[![Report Card](https://goreportcard.com/badge/connectrpc.com/connect)](https://goreportcard.com/report/connectrpc.com/connect)
[![GoDoc](https://pkg.go.dev/badge/connectrpc.com/connect.svg)](https://pkg.go.dev/connectrpc.com/connect)
[![Slack](https://img.shields.io/badge/slack-buf-%23e01563)][slack]
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/8972/badge)](https://www.bestpractices.dev/projects/8972)
Connect is a slim library for building browser and gRPC-compatible HTTP APIs.
You write a short [Protocol Buffer][protobuf] schema and implement your
application logic, and Connect generates code to handle marshaling, routing,
compression, and content type negotiation. It also generates an idiomatic,
type-safe client. Handlers and clients support three protocols: gRPC, gRPC-Web,
and Connect's own protocol.
The [Connect protocol][protocol] is a simple protocol that works over HTTP/1.1
or HTTP/2. It takes the best portions of gRPC and gRPC-Web, including
streaming, and packages them into a protocol that works equally well in
browsers, monoliths, and microservices. Calling a Connect API is as easy as
using `curl`. Try it with our live demo:
```
curl \
--header "Content-Type: application/json" \
--data '{"sentence": "I feel happy."}' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say
```
Handlers and clients also support the gRPC and gRPC-Web protocols, including
streaming, headers, trailers, and error details. gRPC-compatible [server
reflection][grpcreflect] and [health checks][grpchealth] are available as
standalone packages. Instead of cURL, we could call our API with a gRPC client:
```
go install github.com/bufbuild/buf/cmd/buf@latest
buf curl --protocol grpc \
--data '{"sentence": "I feel happy."}' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say
```
Under the hood, Connect is just [Protocol Buffers][protobuf] and the standard
library: no custom HTTP implementation, no new name resolution or load
balancing APIs, and no surprises. Everything you already know about `net/http`
still applies, and any package that works with an `http.Server`, `http.Client`,
or `http.Handler` also works with Connect.
For more on Connect, see the [announcement blog post][blog], the documentation
on [connectrpc.com][docs] (especially the [Getting Started] guide for Go), the
[demo service][examples-go], or the [protocol specification][protocol].
## A small example
Curious what all this looks like in practice? From a [Protobuf
schema](internal/proto/connect/ping/v1/ping.proto), we generate [a small RPC
package](internal/gen/simple/connect/ping/v1/pingv1connect/ping.connect.go). Using that
package, we can build a server. This example is available at [internal/example](internal/example):
```go
package main
import (
"context"
"log"
"net/http"
"connectrpc.com/connect"
pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1"
"connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect"
"connectrpc.com/validate"
)
type PingServer struct {
pingv1connect.UnimplementedPingServiceHandler // returns errors from all methods
}
func (ps *PingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) {
return &pingv1.PingResponse{
Number: req.Number,
}, nil
}
func main() {
mux := http.NewServeMux()
// The generated constructors return a path and a plain net/http
// handler.
mux.Handle(
pingv1connect.NewPingServiceHandler(
&PingServer{},
// Validation via Protovalidate is almost always recommended
connect.WithInterceptors(validate.NewInterceptor()),
),
)
p := new(http.Protocols)
p.SetHTTP1(true)
// For gRPC clients, it's convenient to support HTTP/2 without TLS.
p.SetUnencryptedHTTP2(true)
s := &http.Server{
Addr: "localhost:8080",
Handler: mux,
Protocols: p,
}
if err := s.ListenAndServe(); err != nil {
log.Fatalf("listen failed: %v", err)
}
}
```
With that server running, you can make requests with any gRPC or Connect
client. To write a client using Connect:
```go
package main
import (
"context"
"log"
"net/http"
pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1"
"connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect"
)
func main() {
client := pingv1connect.NewPingServiceClient(
http.DefaultClient,
"http://localhost:8080/",
)
req := &pingv1.PingRequest{Number: 42}
res, err := client.Ping(context.Background(), req)
if err != nil {
log.Fatalln(err)
}
log.Println(res)
}
```
Of course, `http.ListenAndServe` and `http.DefaultClient` aren't fit for
production use! See Connect's [deployment docs][docs-deployment] for a guide to
configuring timeouts, connection pools, observability, and h2c.
## Ecosystem
* [grpchealth]: gRPC-compatible health checks for connect-go
* [grpcreflect]: gRPC-compatible server reflection for connect-go
* [validate]: [Protovalidate][protovalidate] interceptor for connect-go
* [examples-go]: service powering [demo.connectrpc.com](https://demo.connectrpc.com), including bidi streaming
* [connect-es]: Type-safe APIs with Protobuf and TypeScript
* [Buf Studio]: web UI for ad-hoc RPCs
* [conformance]: Connect, gRPC, and gRPC-Web interoperability tests
## Status: Stable
This module is stable. It supports:
* The two most recent major releases of Go (the same versions of Go that continue
to [receive security patches][go-support-policy]).
* [APIv2] of Protocol Buffers in Go (`google.golang.org/protobuf`).
Within those parameters, `connect` follows semantic versioning. We will
_not_ make breaking changes in the 1.x series of releases.
## Legal
Offered under the [Apache 2 license][license].
[APIv2]: https://blog.golang.org/protobuf-apiv2
[Buf Studio]: https://buf.build/studio
[Getting Started]: https://connectrpc.com/docs/go/getting-started
[blog]: https://buf.build/blog/connect-a-better-grpc
[conformance]: https://github.com/connectrpc/conformance
[grpchealth]: https://github.com/connectrpc/grpchealth-go
[grpcreflect]: https://github.com/connectrpc/grpcreflect-go
[connect-es]: https://github.com/connectrpc/connect-es
[examples-go]: https://github.com/connectrpc/examples-go
[docs-deployment]: https://connectrpc.com/docs/go/deployment
[docs]: https://connectrpc.com
[go-support-policy]: https://golang.org/doc/devel/release#policy
[license]: https://github.com/connectrpc/connect-go/blob/main/LICENSE
[protobuf]: https://developers.google.com/protocol-buffers
[protocol]: https://connectrpc.com/docs/protocol
[slack]: https://buf.build/links/slack
[validate]: https://github.com/connectrpc/validate-go
[protovalidate]: https://protovalidate.com
+44
View File
@@ -0,0 +1,44 @@
# Releasing connect-go
This document outlines how to create a release of connect-go.
1. Clone the repo, ensuring you have the latest main.
2. On a new branch, open [connect.go](connect.go) and change the `Version` constant to an appropriate [semantic version](https://semver.org/). To select the correct version, look at the version number of the [latest release] and the changes that are included in this new release.
* If there are only bug fixes and no new features, remove the `-dev` suffix, set MINOR number to be equal to the [latest release], and set the PATCH number to be 1 more than the PATCH number of the [latest release].
* If there are features being released, remove the `-dev` suffix, set the MINOR number to be 1 more than the MINOR number of the [latest release], and set the PATCH number to `0`. In the common case, the diff here will just be to remove the `-dev` suffix.
```patch
-const Version = "1.14.0-dev"
+const Version = "1.14.0"
```
3. Check for any changes in [cmd/protoc-gen-connect-go/main.go](cmd/protoc-gen-connect-go/main.go) that require a version restriction. A constant `IsAtLeastVersionX_Y_Z` should be defined in [connect.go](connect.go) if generated code has begun to use a new API. Make sure the generated code references this constant. If a new constant has been added since the last release, ensure that the name of the constant matches the version being released ([Example PR #496](https://github.com/connectrpc/connect-go/pull/496)).
4. Open a PR titled "Prepare for vX.Y.Z" ([Example PR #661](https://github.com/connectrpc/connect-go/pull/661)) and a description tagging all current maintainers. Once it's reviewed and CI passes, merge it.
*Make sure no new commits are merged until the release is complete.*
5. Review all commits in the new release and for each PR check an appropriate label is used and edit the title to be meaninful to end users. This will help auto-generated release notes match the final notes as closely as possible.
6. Using the Github UI, create a new release.
- Under “Choose a tag”, type in “vX.Y.Z” to create a new tag for the release upon publish.
- Target the main branch.
- Title the Release “vX.Y.Z”.
- Click “set as latest release”.
- Set the last version as the “Previous tag”.
- Click “Generate release notes” to autogenerate release notes.
- Edit the release notes. A summary and other sub categories may be added if required but should, in most cases, be left as ### Enhancements and ### Bugfixes. Feel free to collect multiple small changes to docs or Github config into one line, but try to tag every contributor. Make especially sure to credit new external contributors!
7. Publish the release.
8. On a new branch, open [connect.go](connect.go) and change the `Version` to increment the minor tag and append the `-dev` suffix. Use the next minor release - we never anticipate bugs and patch releases.
```patch
-const Version = "1.14.0"
+const Version = "1.15.0-dev"
```
9. Open a PR titled "Back to development" ([Example PR #662](https://github.com/connectrpc/connect-go/pull/662)). Once it's reviewed and CI passes, merge it.
[latest release]: https://github.com/connectrpc/connect-go/releases/latest
+5
View File
@@ -0,0 +1,5 @@
Security Policy
===============
This project follows the [Connect security policy and reporting
process](https://connectrpc.com/docs/governance/security).
+19
View File
@@ -0,0 +1,19 @@
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
value: connectrpc.com/connect/internal/gen
plugins:
- local: protoc-gen-go
out: internal/gen
opt: paths=source_relative
- local: protoc-gen-connect-go
out: internal/gen/generics
opt: paths=source_relative
- local: protoc-gen-connect-go
out: internal/gen/simple
opt:
- paths=source_relative
- simple
clean: true
+14
View File
@@ -0,0 +1,14 @@
version: v2
modules:
- path: internal/proto
lint:
use:
- STANDARD
ignore:
- internal/proto/connectext/grpc/health/v1/health.proto
- internal/proto/connectext/grpc/reflection/v1alpha/reflection.proto
- internal/proto/connectext/grpc/status/v1/status.proto
disallow_comment_ignores: true
breaking:
use:
- WIRE_JSON
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"bytes"
"sync"
)
const (
initialBufferSize = 512
maxRecycleBufferSize = 8 * 1024 * 1024 // if >8MiB, don't hold onto a buffer
)
type bufferPool struct {
sync.Pool
}
func newBufferPool() *bufferPool {
return &bufferPool{
Pool: sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, initialBufferSize))
},
},
}
}
func (b *bufferPool) Get() *bytes.Buffer {
if buf, ok := b.Pool.Get().(*bytes.Buffer); ok {
return buf
}
return bytes.NewBuffer(make([]byte, 0, initialBufferSize))
}
func (b *bufferPool) Put(buffer *bytes.Buffer) {
if buffer.Cap() > maxRecycleBufferSize {
return
}
buffer.Reset()
b.Pool.Put(buffer)
}
+392
View File
@@ -0,0 +1,392 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// Client is a reusable, concurrency-safe client for a single procedure.
// Depending on the procedure's type, use the CallUnary, CallClientStream,
// CallServerStream, or CallBidiStream method.
//
// By default, clients use the Connect protocol with the binary Protobuf Codec,
// ask for gzipped responses, and send uncompressed requests. To use the gRPC
// or gRPC-Web protocols, use the [WithGRPC] or [WithGRPCWeb] options.
type Client[Req, Res any] struct {
config *clientConfig
callUnary func(context.Context, *Request[Req]) (*Response[Res], error)
protocolClient protocolClient
err error
}
// NewClient constructs a new Client.
func NewClient[Req, Res any](httpClient HTTPClient, url string, options ...ClientOption) *Client[Req, Res] {
client := &Client[Req, Res]{}
config, err := newClientConfig(url, options)
if err != nil {
client.err = err
return client
}
client.config = config
protocolClient, protocolErr := client.config.Protocol.NewClient(
&protocolClientParams{
CompressionName: config.RequestCompressionName,
CompressionPools: newReadOnlyCompressionPools(
config.CompressionPools,
config.CompressionNames,
),
Codec: config.Codec,
Protobuf: config.protobuf(),
CompressMinBytes: config.CompressMinBytes,
HTTPClient: httpClient,
URL: config.URL,
BufferPool: config.BufferPool,
ReadMaxBytes: config.ReadMaxBytes,
SendMaxBytes: config.SendMaxBytes,
EnableGet: config.EnableGet,
GetURLMaxBytes: config.GetURLMaxBytes,
GetUseFallback: config.GetUseFallback,
},
)
if protocolErr != nil {
client.err = protocolErr
return client
}
client.protocolClient = protocolClient
// Rather than applying unary interceptors along the hot path, we can do it
// once at client creation.
unarySpec := config.newSpec(StreamTypeUnary)
unaryFunc := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) {
conn := client.protocolClient.NewConn(ctx, unarySpec, request.Header())
conn.onRequestSend(func(r *http.Request) {
request.setRequestMethod(r.Method)
callInfo, ok := clientCallInfoForContext(ctx)
if ok {
callInfo.method = r.Method
callInfo.responseSource = conn
}
})
// Send always returns an io.EOF unless the error is from the client-side.
// We want the user to continue to call Receive in those cases to get the
// full error from the server-side.
if err := conn.Send(request.Any()); err != nil && !errors.Is(err, io.EOF) {
_ = conn.CloseRequest()
_ = conn.CloseResponse()
return nil, err
}
if err := conn.CloseRequest(); err != nil {
_ = conn.CloseResponse()
return nil, err
}
response, err := receiveUnaryResponse[Res](conn, config.Initializer)
if err != nil {
_ = conn.CloseResponse()
return nil, err
}
return response, conn.CloseResponse()
})
if interceptor := config.Interceptor; interceptor != nil {
// interceptor is the full chain of all interceptors provided
unaryFunc = interceptor.WrapUnary(unaryFunc)
}
client.callUnary = func(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
// To make the specification, peer, and RPC headers visible to the full
// interceptor chain (as though they were supplied by the caller), we'll
// add them here.
request.spec = unarySpec
request.peer = client.protocolClient.Peer()
protocolClient.WriteRequestHeader(StreamTypeUnary, request.Header())
// Also set them in the context if there's a call info present
callInfo, callInfoOk := clientCallInfoForContext(ctx)
if callInfoOk {
callInfo.peer = request.Peer()
callInfo.spec = request.Spec()
// A client could have set request headers in the call info OR the request wrapper
// So if a callInfo exists in context, merge any headers from there into the request wrapper
// so that all headers are sent in the request
mergeHeaders(request.Header(), callInfo.requestHeader)
// Copy the call info into a sentinel value. This is so we can compare
// the sentinel value against the call info in context. If they're different,
// we can stop the request. This protects against changing the context in interceptors.
ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo)
}
response, err := unaryFunc(ctx, request)
if err != nil {
return nil, err
}
typed, ok := response.(*Response[Res])
if !ok {
return nil, errorf(CodeInternal, "unexpected client response type %T", response)
}
return typed, nil
}
return client
}
// CallUnary calls a request-response procedure.
func (c *Client[Req, Res]) CallUnary(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
if c.err != nil {
return nil, c.err
}
return c.callUnary(ctx, request)
}
// CallClientStream calls a client streaming procedure.
//
// Request headers can be sent via the [ClientStreamForClient.RequestHeader] method on the stream. Note that the
// request headers are not sent automatically when this method is invoked and instead require an explicit call to
// [ClientStreamForClient.Send].
func (c *Client[Req, Res]) CallClientStream(ctx context.Context) *ClientStreamForClient[Req, Res] {
if c.err != nil {
return &ClientStreamForClient[Req, Res]{err: c.err}
}
return &ClientStreamForClient[Req, Res]{
conn: c.newConn(ctx, StreamTypeClient, nil),
initializer: c.config.Initializer,
}
}
// CallClientStreamSimple calls a client streaming procedure.
//
// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers are
// transmitted when this method is called and do not require an explicit call to [ClientStreamForClientSimple.Send].
//
// In addition, when calling [ClientStreamForClientSimple.CloseAndReceive] on the returned stream, the returned response
// is the response type defined for the stream and _not_ a Connect [Response] wrapper type. As a result, any response
// headers and trailers should be read from the [CallInfo] object in context.
func (c *Client[Req, Res]) CallClientStreamSimple(ctx context.Context) (*ClientStreamForClientSimple[Req, Res], error) {
if c.err != nil {
return &ClientStreamForClientSimple[Req, Res]{
stream: &ClientStreamForClient[Req, Res]{err: c.err},
}, c.err
}
stream := &ClientStreamForClientSimple[Req, Res]{
stream: &ClientStreamForClient[Req, Res]{
conn: c.newConn(ctx, StreamTypeClient, nil),
initializer: c.config.Initializer,
},
}
if err := stream.Send(nil); err != nil {
return nil, err
}
return stream, nil
}
// CallServerStream calls a server streaming procedure.
func (c *Client[Req, Res]) CallServerStream(ctx context.Context, request *Request[Req]) (*ServerStreamForClient[Res], error) {
if c.err != nil {
return nil, c.err
}
conn := c.newConn(ctx, StreamTypeServer, func(r *http.Request) {
request.method = r.Method
})
request.peer = conn.Peer()
request.spec = conn.Spec()
mergeHeaders(conn.RequestHeader(), request.header)
// Send always returns an io.EOF unless the error is from the client-side.
// We want the user to continue to call Receive in those cases to get the
// full error from the server-side.
if err := conn.Send(request.Msg); err != nil && !errors.Is(err, io.EOF) {
_ = conn.CloseRequest()
_ = conn.CloseResponse()
return nil, err
}
if err := conn.CloseRequest(); err != nil {
return nil, err
}
return &ServerStreamForClient[Res]{
conn: conn,
initializer: c.config.Initializer,
}, nil
}
// CallBidiStream calls a bidirectional streaming procedure.
//
// Request headers can be sent via the [BidiStreamForClient.RequestHeader] method. Note that the
// request headers are not sent automatically when this method is invoked and instead require an explicit call to
// [BidiStreamForClient.Send].
func (c *Client[Req, Res]) CallBidiStream(ctx context.Context) *BidiStreamForClient[Req, Res] {
if c.err != nil {
return &BidiStreamForClient[Req, Res]{err: c.err}
}
return &BidiStreamForClient[Req, Res]{
conn: c.newConn(ctx, StreamTypeBidi, nil),
initializer: c.config.Initializer,
}
}
// CallBidiStreamSimple calls a bidirectional streaming procedure.
//
// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers
// are transmitted when this method is called and do not require an explicit call to [BidiStreamForClient.Send].
//
// Likewise, response headers and trailers should be read from the [CallInfo] object in context.
func (c *Client[Req, Res]) CallBidiStreamSimple(ctx context.Context) (*BidiStreamForClientSimple[Req, Res], error) {
if c.err != nil {
return &BidiStreamForClientSimple[Req, Res]{
stream: &BidiStreamForClient[Req, Res]{err: c.err},
}, c.err
}
stream := &BidiStreamForClientSimple[Req, Res]{
stream: &BidiStreamForClient[Req, Res]{
conn: c.newConn(ctx, StreamTypeBidi, nil),
initializer: c.config.Initializer,
},
}
if err := stream.Send(nil); err != nil {
return nil, err
}
return stream, nil
}
func (c *Client[Req, Res]) newConn(ctx context.Context, streamType StreamType, onRequestSend func(r *http.Request)) StreamingClientConn {
callInfo, callInfoOk := clientCallInfoForContext(ctx)
// Set values in the context if there's a call info present
if callInfoOk {
// Copy the call info into a sentinel value. This is so we can compare
// the sentinel value against the call info in context. If they're different,
// we can stop the request. This protects against changing the context in interceptors.
ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo)
}
newConn := func(ctx context.Context, spec Spec) StreamingClientConn {
header := make(http.Header, 8) // arbitrary power of two, prevent immediate resizing
c.protocolClient.WriteRequestHeader(streamType, header)
conn := c.protocolClient.NewConn(ctx, spec, header)
conn.onRequestSend(onRequestSend)
return conn
}
if interceptor := c.config.Interceptor; interceptor != nil {
newConn = interceptor.WrapStreamingClient(newConn)
}
conn := newConn(ctx, c.config.newSpec(streamType))
// Set values in the context if there's a call info present
if callInfoOk {
callInfo.peer = conn.Peer()
callInfo.spec = conn.Spec()
callInfo.responseSource = conn
// Merge any callInfo request headers first, then do the request,
// so that context headers show first in the list of headers.
mergeHeaders(conn.RequestHeader(), callInfo.RequestHeader())
}
return conn
}
type clientConfig struct {
URL *url.URL
Protocol protocol
Procedure string
Schema any
Initializer maybeInitializer
CompressMinBytes int
Interceptor Interceptor
CompressionPools map[string]*compressionPool
CompressionNames []string
Codec Codec
RequestCompressionName string
BufferPool *bufferPool
ReadMaxBytes int
SendMaxBytes int
EnableGet bool
GetURLMaxBytes int
GetUseFallback bool
IdempotencyLevel IdempotencyLevel
}
func newClientConfig(rawURL string, options []ClientOption) (*clientConfig, *Error) {
url, err := parseRequestURL(rawURL)
if err != nil {
return nil, err
}
protoPath := extractProtoPath(url.Path)
config := clientConfig{
URL: url,
Protocol: &protocolConnect{},
Procedure: protoPath,
CompressionPools: make(map[string]*compressionPool),
BufferPool: newBufferPool(),
}
withProtoBinaryCodec().applyToClient(&config)
withGzip().applyToClient(&config)
for _, opt := range options {
opt.applyToClient(&config)
}
if err := config.validate(); err != nil {
return nil, err
}
return &config, nil
}
func (c *clientConfig) validate() *Error {
if c.Codec == nil || c.Codec.Name() == "" {
return errorf(CodeUnknown, "no codec configured")
}
if c.RequestCompressionName != "" && c.RequestCompressionName != compressionIdentity {
if _, ok := c.CompressionPools[c.RequestCompressionName]; !ok {
return errorf(CodeUnknown, "unknown compression %q", c.RequestCompressionName)
}
}
return nil
}
func (c *clientConfig) protobuf() Codec {
if c.Codec.Name() == codecNameProto {
return c.Codec
}
return &protoBinaryCodec{}
}
func (c *clientConfig) newSpec(t StreamType) Spec {
return Spec{
StreamType: t,
Procedure: c.Procedure,
Schema: c.Schema,
IsClient: true,
IdempotencyLevel: c.IdempotencyLevel,
}
}
func parseRequestURL(rawURL string) (*url.URL, *Error) {
url, err := url.ParseRequestURI(rawURL)
if err == nil {
return url, nil
}
if !strings.Contains(rawURL, "://") {
// URL doesn't have a scheme, so the user is likely accustomed to
// grpc-go's APIs.
err = fmt.Errorf(
"URL %q missing scheme: use http:// or https:// (unlike grpc-go)",
rawURL,
)
}
return nil, NewError(CodeUnavailable, err)
}
+441
View File
@@ -0,0 +1,441 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"errors"
"io"
"net/http"
)
var (
// errNoStreamInitialized signals that a no stream has been initialized when
// attempting to access stream-related methods.
errNoStreamInitialized = errors.New("no stream initialized")
)
// ClientStreamForClient is the client's view of a client streaming RPC.
//
// It's returned from [Client].CallClientStream, but doesn't currently have an
// exported constructor function.
//
// When using this stream, request headers should be set via the [ClientStreamForClient.RequestHeader] method.
type ClientStreamForClient[Req, Res any] struct {
conn StreamingClientConn
initializer maybeInitializer
// Error from client construction. If non-nil, return for all calls.
err error
}
// Spec returns the specification for the RPC.
func (c *ClientStreamForClient[_, _]) Spec() Spec {
return c.conn.Spec()
}
// Peer describes the server for the RPC.
func (c *ClientStreamForClient[_, _]) Peer() Peer {
return c.conn.Peer()
}
// RequestHeader returns the request headers. Headers are sent to the server with the
// first call to Send.
//
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols. Applications shouldn't write them.
func (c *ClientStreamForClient[Req, Res]) RequestHeader() http.Header {
if c.err != nil {
return http.Header{}
}
return c.conn.RequestHeader()
}
// Send a message to the server. The first call to Send also sends the request
// headers.
//
// If the server returns an error, Send returns an error that wraps [io.EOF].
// Clients should check for case using the standard library's [errors.Is] and
// unmarshal the error using CloseAndReceive.
func (c *ClientStreamForClient[Req, Res]) Send(request *Req) error {
if c.err != nil {
return c.err
}
if request == nil {
return c.conn.Send(nil)
}
return c.conn.Send(request)
}
// CloseAndReceive closes the send side of the stream and waits for the
// response.
func (c *ClientStreamForClient[Req, Res]) CloseAndReceive() (*Response[Res], error) {
if c.err != nil {
return nil, c.err
}
if err := c.conn.CloseRequest(); err != nil {
_ = c.conn.CloseResponse()
return nil, err
}
response, err := receiveUnaryResponse[Res](c.conn, c.initializer)
if err != nil {
_ = c.conn.CloseResponse()
return nil, err
}
return response, c.conn.CloseResponse()
}
// Conn exposes the underlying StreamingClientConn. This may be useful if
// you'd prefer to wrap the connection in a different high-level API.
func (c *ClientStreamForClient[Req, Res]) Conn() (StreamingClientConn, error) {
return c.conn, c.err
}
// ClientStreamForClientSimple is the client's view of a client streaming RPC.
//
// It's returned from [Client.CallClientStreamSimple], but doesn't currently have an
// exported constructor function.
//
// Usage of this stream requires that request headers be set in a [CallInfo] object in context via [NewClientContext].
// In addition, the response returned by [ClientStreamForClientSimple.CloseAndReceive] is the response type defined for
// the stream and _not_ a Connect [Response] wrapper type. As a result, response headers/trailers should be read from
// the [CallInfo] object in context.
type ClientStreamForClientSimple[Req, Res any] struct {
stream *ClientStreamForClient[Req, Res]
}
// Spec returns the specification for the RPC.
func (c *ClientStreamForClientSimple[_, _]) Spec() Spec {
if c.stream == nil {
return Spec{}
}
return c.stream.Spec()
}
// Peer describes the server for the RPC.
func (c *ClientStreamForClientSimple[_, _]) Peer() Peer {
if c.stream == nil {
return Peer{}
}
return c.stream.Peer()
}
// Send a message to the server. The first call to Send also sends the request
// headers.
//
// If the server returns an error, Send returns an error that wraps [io.EOF].
// Clients should check for case using the standard library's [errors.Is] and
// unmarshal the error using CloseAndReceive.
func (c *ClientStreamForClientSimple[Req, Res]) Send(request *Req) error {
if c.stream == nil {
return errNoStreamInitialized
}
return c.stream.Send(request)
}
// CloseAndReceive closes the send side of the stream and waits for the
// response.
func (c *ClientStreamForClientSimple[Req, Res]) CloseAndReceive() (*Res, error) {
if c.stream == nil {
return nil, errNoStreamInitialized
}
res, err := c.stream.CloseAndReceive()
if err != nil {
return nil, err
}
return res.Msg, nil
}
// ServerStreamForClient is the client's view of a server streaming RPC.
//
// It's returned from [Client].CallServerStream, but doesn't currently have an
// exported constructor function.
type ServerStreamForClient[Res any] struct {
conn StreamingClientConn
initializer maybeInitializer
msg *Res
// Error from client construction. If non-nil, return for all calls.
constructErr error
// Error from conn.Receive().
receiveErr error
}
// Receive advances the stream to the next message, which will then be
// available through the Msg method. It returns false when the stream stops,
// either by reaching the end or by encountering an unexpected error. After
// Receive returns false, the Err method will return any unexpected error
// encountered.
func (s *ServerStreamForClient[Res]) Receive() bool {
if s.constructErr != nil || s.receiveErr != nil {
return false
}
s.msg = new(Res)
if err := s.initializer.maybe(s.conn.Spec(), s.msg); err != nil {
s.receiveErr = err
return false
}
s.receiveErr = s.conn.Receive(s.msg)
return s.receiveErr == nil
}
// Msg returns the most recent message unmarshaled by a call to Receive.
func (s *ServerStreamForClient[Res]) Msg() *Res {
if s.msg == nil {
s.msg = new(Res)
}
return s.msg
}
// Err returns the first non-EOF error that was encountered by Receive.
func (s *ServerStreamForClient[Res]) Err() error {
if s.constructErr != nil {
return s.constructErr
}
if s.receiveErr != nil && !errors.Is(s.receiveErr, io.EOF) {
return s.receiveErr
}
return nil
}
// ResponseHeader returns the headers received from the server. It blocks until
// the first call to Receive returns.
func (s *ServerStreamForClient[Res]) ResponseHeader() http.Header {
if s.constructErr != nil {
return http.Header{}
}
return s.conn.ResponseHeader()
}
// ResponseTrailer returns the trailers received from the server. Trailers
// aren't fully populated until Receive() returns an error wrapping io.EOF.
func (s *ServerStreamForClient[Res]) ResponseTrailer() http.Header {
if s.constructErr != nil {
return http.Header{}
}
return s.conn.ResponseTrailer()
}
// Close the receive side of the stream.
//
// Close is non-blocking. To gracefully close the stream and allow for
// connection resuse ensure all messages have been received before calling
// Close. All messages are received when Receive returns false.
func (s *ServerStreamForClient[Res]) Close() error {
if s.constructErr != nil {
return s.constructErr
}
return s.conn.CloseResponse()
}
// Conn exposes the underlying StreamingClientConn. This may be useful if
// you'd prefer to wrap the connection in a different high-level API.
func (s *ServerStreamForClient[Res]) Conn() (StreamingClientConn, error) {
return s.conn, s.constructErr
}
// BidiStreamForClient is the client's view of a bidirectional streaming RPC.
//
// It's returned from [Client].CallBidiStream, but doesn't currently have an
// exported constructor function.
type BidiStreamForClient[Req, Res any] struct {
conn StreamingClientConn
initializer maybeInitializer
// Error from client construction. If non-nil, return for all calls.
err error
}
// Spec returns the specification for the RPC.
func (b *BidiStreamForClient[_, _]) Spec() Spec {
return b.conn.Spec()
}
// Peer describes the server for the RPC.
func (b *BidiStreamForClient[_, _]) Peer() Peer {
return b.conn.Peer()
}
// RequestHeader returns the request headers. Headers are sent with the first
// call to Send.
//
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols. Applications shouldn't write them.
func (b *BidiStreamForClient[Req, Res]) RequestHeader() http.Header {
if b.err != nil {
return http.Header{}
}
return b.conn.RequestHeader()
}
// Send a message to the server. The first call to Send also sends the request
// headers. To send just the request headers, without a body, call Send with a
// nil pointer.
//
// If the server returns an error, Send returns an error that wraps [io.EOF].
// Clients should check for EOF using the standard library's [errors.Is] and
// call Receive to retrieve the error.
func (b *BidiStreamForClient[Req, Res]) Send(msg *Req) error {
if b.err != nil {
return b.err
}
if msg == nil {
return b.conn.Send(nil)
}
return b.conn.Send(msg)
}
// CloseRequest closes the send side of the stream.
func (b *BidiStreamForClient[Req, Res]) CloseRequest() error {
if b.err != nil {
return b.err
}
return b.conn.CloseRequest()
}
// Receive a message. When the server is done sending messages and no other
// errors have occurred, Receive will return an error that wraps [io.EOF].
func (b *BidiStreamForClient[Req, Res]) Receive() (*Res, error) {
if b.err != nil {
return nil, b.err
}
var msg Res
if err := b.initializer.maybe(b.conn.Spec(), &msg); err != nil {
return nil, err
}
if err := b.conn.Receive(&msg); err != nil {
return nil, err
}
return &msg, nil
}
// CloseResponse closes the receive side of the stream.
//
// CloseResponse is non-blocking. To gracefully close the stream and allow for
// connection resuse ensure all messages have been received before calling
// CloseResponse. All messages are received when Receive returns an error
// wrapping [io.EOF].
func (b *BidiStreamForClient[Req, Res]) CloseResponse() error {
if b.err != nil {
return b.err
}
return b.conn.CloseResponse()
}
// ResponseHeader returns the headers received from the server. It blocks until
// the first call to Receive returns.
func (b *BidiStreamForClient[Req, Res]) ResponseHeader() http.Header {
if b.err != nil {
return http.Header{}
}
return b.conn.ResponseHeader()
}
// ResponseTrailer returns the trailers received from the server. Trailers
// aren't fully populated until Receive() returns an error wrapping [io.EOF].
func (b *BidiStreamForClient[Req, Res]) ResponseTrailer() http.Header {
if b.err != nil {
return http.Header{}
}
return b.conn.ResponseTrailer()
}
// Conn exposes the underlying StreamingClientConn. This may be useful if
// you'd prefer to wrap the connection in a different high-level API.
func (b *BidiStreamForClient[Req, Res]) Conn() (StreamingClientConn, error) {
return b.conn, b.err
}
// BidiStreamForClientSimple is the client's view of a bidirectional streaming RPC.
//
// It's returned from [Client].CallBidiStream, but doesn't currently have an
// exported constructor function.
type BidiStreamForClientSimple[Req, Res any] struct {
stream *BidiStreamForClient[Req, Res]
}
// Spec returns the specification for the RPC.
func (b *BidiStreamForClientSimple[_, _]) Spec() Spec {
if b.stream == nil {
return Spec{}
}
return b.stream.Spec()
}
// Peer describes the server for the RPC.
func (b *BidiStreamForClientSimple[_, _]) Peer() Peer {
if b.stream == nil {
return Peer{}
}
return b.stream.Peer()
}
// Send a message to the server. The first call to Send also sends the request
// headers. To send just the request headers, without a body, call Send with a
// nil pointer.
//
// If the server returns an error, Send returns an error that wraps [io.EOF].
// Clients should check for EOF using the standard library's [errors.Is] and
// call Receive to retrieve the error.
func (b *BidiStreamForClientSimple[Req, Res]) Send(msg *Req) error {
if b.stream == nil {
return errNoStreamInitialized
}
return b.stream.Send(msg)
}
// CloseRequest closes the send side of the stream.
func (b *BidiStreamForClientSimple[Req, Res]) CloseRequest() error {
if b.stream == nil {
return errNoStreamInitialized
}
return b.stream.CloseRequest()
}
// Receive a message. When the server is done sending messages and no other
// errors have occurred, Receive will return an error that wraps [io.EOF].
func (b *BidiStreamForClientSimple[Req, Res]) Receive() (*Res, error) {
if b.stream == nil {
return nil, errNoStreamInitialized
}
return b.stream.Receive()
}
// CloseResponse closes the receive side of the stream.
//
// CloseResponse is non-blocking. To gracefully close the stream and allow for
// connection resuse ensure all messages have been received before calling
// CloseResponse. All messages are received when Receive returns an error
// wrapping [io.EOF].
func (b *BidiStreamForClientSimple[Req, Res]) CloseResponse() error {
if b.stream == nil {
return errNoStreamInitialized
}
return b.stream.CloseResponse()
}
// ResponseHeader returns the headers received from the server. It blocks until
// the first call to Receive returns.
func (b *BidiStreamForClientSimple[Req, Res]) ResponseHeader() http.Header {
if b.stream == nil {
return make(http.Header)
}
return b.stream.ResponseHeader()
}
// ResponseTrailer returns the trailers received from the server. Trailers
// aren't fully populated until Receive() returns an error wrapping [io.EOF].
func (b *BidiStreamForClientSimple[Req, Res]) ResponseTrailer() http.Header {
if b.stream == nil {
return make(http.Header)
}
return b.stream.ResponseTrailer()
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"fmt"
"strconv"
"strings"
)
// A Code is one of the Connect protocol's error codes. There are no user-defined
// codes, so only the codes enumerated below are valid. In both name and
// semantics, these codes match the gRPC status codes.
//
// The descriptions below are optimized for brevity rather than completeness.
// See the [Connect protocol specification] for detailed descriptions of each
// code and example usage.
//
// [Connect protocol specification]: https://connectrpc.com/docs/protocol
type Code uint32
const (
// The zero code in gRPC is OK, which indicates that the operation was a
// success. We don't define a constant for it because it overlaps awkwardly
// with Go's error semantics: what does it mean to have a non-nil error with
// an OK status? (Also, the Connect protocol doesn't use a code for
// successes.)
// CodeCanceled indicates that the operation was canceled, typically by the
// caller.
CodeCanceled Code = 1
// CodeUnknown indicates that the operation failed for an unknown reason.
CodeUnknown Code = 2
// CodeInvalidArgument indicates that client supplied an invalid argument.
CodeInvalidArgument Code = 3
// CodeDeadlineExceeded indicates that deadline expired before the operation
// could complete.
CodeDeadlineExceeded Code = 4
// CodeNotFound indicates that some requested entity (for example, a file or
// directory) was not found.
CodeNotFound Code = 5
// CodeAlreadyExists indicates that client attempted to create an entity (for
// example, a file or directory) that already exists.
CodeAlreadyExists Code = 6
// CodePermissionDenied indicates that the caller doesn't have permission to
// execute the specified operation.
CodePermissionDenied Code = 7
// CodeResourceExhausted indicates that some resource has been exhausted. For
// example, a per-user quota may be exhausted or the entire file system may
// be full.
CodeResourceExhausted Code = 8
// CodeFailedPrecondition indicates that the system is not in a state
// required for the operation's execution.
CodeFailedPrecondition Code = 9
// CodeAborted indicates that operation was aborted by the system, usually
// because of a concurrency issue such as a sequencer check failure or
// transaction abort.
CodeAborted Code = 10
// CodeOutOfRange indicates that the operation was attempted past the valid
// range (for example, seeking past end-of-file).
CodeOutOfRange Code = 11
// CodeUnimplemented indicates that the operation isn't implemented,
// supported, or enabled in this service.
CodeUnimplemented Code = 12
// CodeInternal indicates that some invariants expected by the underlying
// system have been broken. This code is reserved for serious errors.
CodeInternal Code = 13
// CodeUnavailable indicates that the service is currently unavailable. This
// is usually temporary, so clients can back off and retry idempotent
// operations.
CodeUnavailable Code = 14
// CodeDataLoss indicates that the operation has resulted in unrecoverable
// data loss or corruption.
CodeDataLoss Code = 15
// CodeUnauthenticated indicates that the request does not have valid
// authentication credentials for the operation.
CodeUnauthenticated Code = 16
minCode = CodeCanceled
maxCode = CodeUnauthenticated
)
func (c Code) String() string {
switch c {
case CodeCanceled:
return "canceled"
case CodeUnknown:
return "unknown"
case CodeInvalidArgument:
return "invalid_argument"
case CodeDeadlineExceeded:
return "deadline_exceeded"
case CodeNotFound:
return "not_found"
case CodeAlreadyExists:
return "already_exists"
case CodePermissionDenied:
return "permission_denied"
case CodeResourceExhausted:
return "resource_exhausted"
case CodeFailedPrecondition:
return "failed_precondition"
case CodeAborted:
return "aborted"
case CodeOutOfRange:
return "out_of_range"
case CodeUnimplemented:
return "unimplemented"
case CodeInternal:
return "internal"
case CodeUnavailable:
return "unavailable"
case CodeDataLoss:
return "data_loss"
case CodeUnauthenticated:
return "unauthenticated"
}
return fmt.Sprintf("code_%d", c)
}
// MarshalText implements [encoding.TextMarshaler].
func (c Code) MarshalText() ([]byte, error) {
return []byte(c.String()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
func (c *Code) UnmarshalText(data []byte) error {
dataStr := string(data)
switch dataStr {
case "canceled":
*c = CodeCanceled
return nil
case "unknown":
*c = CodeUnknown
return nil
case "invalid_argument":
*c = CodeInvalidArgument
return nil
case "deadline_exceeded":
*c = CodeDeadlineExceeded
return nil
case "not_found":
*c = CodeNotFound
return nil
case "already_exists":
*c = CodeAlreadyExists
return nil
case "permission_denied":
*c = CodePermissionDenied
return nil
case "resource_exhausted":
*c = CodeResourceExhausted
return nil
case "failed_precondition":
*c = CodeFailedPrecondition
return nil
case "aborted":
*c = CodeAborted
return nil
case "out_of_range":
*c = CodeOutOfRange
return nil
case "unimplemented":
*c = CodeUnimplemented
return nil
case "internal":
*c = CodeInternal
return nil
case "unavailable":
*c = CodeUnavailable
return nil
case "data_loss":
*c = CodeDataLoss
return nil
case "unauthenticated":
*c = CodeUnauthenticated
return nil
}
// Ensure that non-canonical codes round-trip through MarshalText and
// UnmarshalText.
if after, ok := strings.CutPrefix(dataStr, "code_"); ok {
dataStr = after
code, err := strconv.ParseUint(dataStr, 10 /* base */, 32 /* bitsize */)
if err == nil && (code < uint64(minCode) || code > uint64(maxCode)) {
*c = Code(code)
return nil
}
}
return fmt.Errorf("invalid code %q", dataStr)
}
// CodeOf returns the error's status code if it is or wraps an [*Error] and
// [CodeUnknown] otherwise.
func CodeOf(err error) Code {
if connectErr, ok := asError(err); ok {
return connectErr.Code()
}
return CodeUnknown
}
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/runtime/protoiface"
)
const (
codecNameProto = "proto"
codecNameJSON = "json"
codecNameJSONCharsetUTF8 = codecNameJSON + "; charset=utf-8"
)
// Codec marshals structs (typically generated from a schema) to and from bytes.
type Codec interface {
// Name returns the name of the Codec.
//
// This may be used as part of the Content-Type within HTTP. For example,
// with gRPC this is the content subtype, so "application/grpc+proto" will
// map to the Codec with name "proto".
//
// Names must not be empty.
Name() string
// Marshal marshals the given message.
//
// Marshal may expect a specific type of message, and will error if this type
// is not given.
Marshal(any) ([]byte, error)
// Unmarshal unmarshals the given message.
//
// Unmarshal may expect a specific type of message, and will error if this
// type is not given.
Unmarshal([]byte, any) error
}
// marshalAppender is an extension to Codec for appending to a byte slice.
type marshalAppender interface {
Codec
// MarshalAppend marshals the given message and appends it to the given
// byte slice.
//
// MarshalAppend may expect a specific type of message, and will error if
// this type is not given.
MarshalAppend([]byte, any) ([]byte, error)
}
// stableCodec is an extension to Codec for serializing with stable output.
type stableCodec interface {
Codec
// MarshalStable marshals the given message with stable field ordering.
//
// MarshalStable should return the same output for a given input. Although
// it is not guaranteed to be canonicalized, the marshalling routine for
// MarshalStable will opt for the most normalized output available for a
// given serialization.
//
// For practical reasons, it is possible for MarshalStable to return two
// different results for two inputs considered to be "equal" in their own
// domain, and it may change in the future with codec updates, but for
// any given concrete value and any given version, it should return the
// same output.
MarshalStable(any) ([]byte, error)
// IsBinary returns true if the marshalled data is binary for this codec.
//
// If this function returns false, the data returned from Marshal and
// MarshalStable are considered valid text and may be used in contexts
// where text is expected.
IsBinary() bool
}
type protoBinaryCodec struct{}
var _ Codec = (*protoBinaryCodec)(nil)
func (c *protoBinaryCodec) Name() string { return codecNameProto }
func (c *protoBinaryCodec) Marshal(message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
if !ok {
return nil, errNotProto(message)
}
return proto.Marshal(protoMessage)
}
func (c *protoBinaryCodec) MarshalAppend(dst []byte, message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
if !ok {
return nil, errNotProto(message)
}
return proto.MarshalOptions{}.MarshalAppend(dst, protoMessage)
}
func (c *protoBinaryCodec) Unmarshal(data []byte, message any) error {
protoMessage, ok := message.(proto.Message)
if !ok {
return errNotProto(message)
}
err := proto.Unmarshal(data, protoMessage)
if err != nil {
return fmt.Errorf("unmarshal into %T: %w", message, err)
}
return nil
}
func (c *protoBinaryCodec) MarshalStable(message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
if !ok {
return nil, errNotProto(message)
}
// protobuf does not offer a canonical output today, so this format is not
// guaranteed to match deterministic output from other protobuf libraries.
// In addition, unknown fields may cause inconsistent output for otherwise
// equal messages.
// https://github.com/golang/protobuf/issues/1121
options := proto.MarshalOptions{Deterministic: true}
return options.Marshal(protoMessage)
}
func (c *protoBinaryCodec) IsBinary() bool {
return true
}
type protoJSONCodec struct {
name string
}
var _ Codec = (*protoJSONCodec)(nil)
func (c *protoJSONCodec) Name() string { return c.name }
func (c *protoJSONCodec) Marshal(message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
if !ok {
return nil, errNotProto(message)
}
return protojson.MarshalOptions{}.Marshal(protoMessage)
}
func (c *protoJSONCodec) MarshalAppend(dst []byte, message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
if !ok {
return nil, errNotProto(message)
}
return protojson.MarshalOptions{}.MarshalAppend(dst, protoMessage)
}
func (c *protoJSONCodec) Unmarshal(binary []byte, message any) error {
protoMessage, ok := message.(proto.Message)
if !ok {
return errNotProto(message)
}
if len(binary) == 0 {
return errors.New("zero-length payload is not a valid JSON object")
}
// Discard unknown fields so clients and servers aren't forced to always use
// exactly the same version of the schema.
options := protojson.UnmarshalOptions{DiscardUnknown: true}
err := options.Unmarshal(binary, protoMessage)
if err != nil {
return fmt.Errorf("unmarshal into %T: %w", message, err)
}
return nil
}
func (c *protoJSONCodec) MarshalStable(message any) ([]byte, error) {
// protojson does not offer a "deterministic" field ordering, but fields
// are still ordered consistently by their index. However, protojson can
// output inconsistent whitespace for some reason, therefore it is
// suggested to use a formatter to ensure consistent formatting.
// https://github.com/golang/protobuf/issues/1373
messageJSON, err := c.Marshal(message)
if err != nil {
return nil, err
}
compactedJSON := bytes.NewBuffer(messageJSON[:0])
if err = json.Compact(compactedJSON, messageJSON); err != nil {
return nil, err
}
return compactedJSON.Bytes(), nil
}
func (c *protoJSONCodec) IsBinary() bool {
return false
}
// readOnlyCodecs is a read-only interface to a map of named codecs.
type readOnlyCodecs interface {
// Get gets the Codec with the given name.
Get(string) Codec
// Protobuf gets the user-supplied protobuf codec, falling back to the default
// implementation if necessary.
//
// This is helpful in the gRPC protocol, where the wire protocol requires
// marshaling protobuf structs to binary even if the RPC procedures were
// generated from a different IDL.
Protobuf() Codec
// Names returns a copy of the registered codec names. The returned slice is
// safe for the caller to mutate.
Names() []string
}
func newReadOnlyCodecs(nameToCodec map[string]Codec) readOnlyCodecs {
return &codecMap{
nameToCodec: nameToCodec,
}
}
type codecMap struct {
nameToCodec map[string]Codec
}
func (m *codecMap) Get(name string) Codec {
return m.nameToCodec[name]
}
func (m *codecMap) Protobuf() Codec {
if pb, ok := m.nameToCodec[codecNameProto]; ok {
return pb
}
return &protoBinaryCodec{}
}
func (m *codecMap) Names() []string {
names := make([]string, 0, len(m.nameToCodec))
for name := range m.nameToCodec {
names = append(names, name)
}
return names
}
func errNotProto(message any) error {
if _, ok := message.(protoiface.MessageV1); ok {
return fmt.Errorf("%T uses github.com/golang/protobuf, but connect-go only supports google.golang.org/protobuf: see https://go.dev/blog/protobuf-apiv2", message)
}
return fmt.Errorf("%T doesn't implement proto.Message", message)
}
+224
View File
@@ -0,0 +1,224 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"bytes"
"errors"
"io"
"math"
"net/http"
"strings"
"sync"
)
const (
compressionGzip = "gzip"
compressionIdentity = "identity"
)
// A Decompressor is a reusable wrapper that decompresses an underlying data
// source. The standard library's [*gzip.Reader] implements Decompressor.
type Decompressor interface {
io.Reader
// Close closes the Decompressor, but not the underlying data source. It may
// return an error if the Decompressor wasn't read to EOF.
Close() error
// Reset discards the Decompressor's internal state, if any, and prepares it
// to read from a new source of compressed data.
Reset(io.Reader) error
}
// A Compressor is a reusable wrapper that compresses data written to an
// underlying sink. The standard library's [*gzip.Writer] implements Compressor.
type Compressor interface {
io.Writer
// Close flushes any buffered data to the underlying sink, then closes the
// Compressor. It must not close the underlying sink.
Close() error
// Reset discards the Compressor's internal state, if any, and prepares it to
// write compressed data to a new sink.
Reset(io.Writer)
}
type compressionPool struct {
decompressors sync.Pool
compressors sync.Pool
}
func newCompressionPool(
newDecompressor func() Decompressor,
newCompressor func() Compressor,
) *compressionPool {
if newDecompressor == nil && newCompressor == nil {
return nil
}
return &compressionPool{
decompressors: sync.Pool{
New: func() any { return newDecompressor() },
},
compressors: sync.Pool{
New: func() any { return newCompressor() },
},
}
}
func (c *compressionPool) Decompress(dst *bytes.Buffer, src *bytes.Buffer, readMaxBytes int64) *Error {
decompressor, err := c.getDecompressor(src)
if err != nil {
return errorf(CodeInvalidArgument, "get decompressor: %w", err)
}
reader := io.Reader(decompressor)
if readMaxBytes > 0 && readMaxBytes < math.MaxInt64 {
reader = io.LimitReader(decompressor, readMaxBytes+1)
}
bytesRead, err := dst.ReadFrom(reader)
if err != nil {
_ = c.putDecompressor(decompressor)
err = wrapIfContextError(err)
if connectErr, ok := asError(err); ok {
return connectErr
}
return errorf(CodeInvalidArgument, "decompress: %w", err)
}
if readMaxBytes > 0 && bytesRead > readMaxBytes {
discardedBytes, err := io.Copy(io.Discard, decompressor)
_ = c.putDecompressor(decompressor)
if err != nil {
return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", readMaxBytes, err)
}
return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", bytesRead+discardedBytes, readMaxBytes)
}
if err := c.putDecompressor(decompressor); err != nil {
return errorf(CodeUnknown, "recycle decompressor: %w", err)
}
return nil
}
func (c *compressionPool) Compress(dst *bytes.Buffer, src *bytes.Buffer) *Error {
compressor, err := c.getCompressor(dst)
if err != nil {
return errorf(CodeUnknown, "get compressor: %w", err)
}
if _, err := src.WriteTo(compressor); err != nil {
_ = c.putCompressor(compressor)
err = wrapIfContextError(err)
if connectErr, ok := asError(err); ok {
return connectErr
}
return errorf(CodeInternal, "compress: %w", err)
}
if err := c.putCompressor(compressor); err != nil {
return errorf(CodeInternal, "recycle compressor: %w", err)
}
return nil
}
func (c *compressionPool) getDecompressor(reader io.Reader) (Decompressor, error) {
decompressor, ok := c.decompressors.Get().(Decompressor)
if !ok {
return nil, errors.New("expected Decompressor, got incorrect type from pool")
}
return decompressor, decompressor.Reset(reader)
}
func (c *compressionPool) putDecompressor(decompressor Decompressor) error {
if err := decompressor.Close(); err != nil {
return err
}
// While it's in the pool, we don't want the decompressor to retain a
// reference to the underlying reader. However, most decompressors attempt to
// read some header data from the new data source when Reset; since we don't
// know the compression format, we can't provide a valid header. Since we
// also reset the decompressor when it's pulled out of the pool, we can
// ignore errors here.
_ = decompressor.Reset(http.NoBody)
c.decompressors.Put(decompressor)
return nil
}
func (c *compressionPool) getCompressor(writer io.Writer) (Compressor, error) {
compressor, ok := c.compressors.Get().(Compressor)
if !ok {
return nil, errors.New("expected Compressor, got incorrect type from pool")
}
compressor.Reset(writer)
return compressor, nil
}
func (c *compressionPool) putCompressor(compressor Compressor) error {
if err := compressor.Close(); err != nil {
return err
}
compressor.Reset(io.Discard) // don't keep references
c.compressors.Put(compressor)
return nil
}
// readOnlyCompressionPools is a read-only interface to a map of named
// compressionPools.
type readOnlyCompressionPools interface {
Get(string) *compressionPool
Contains(string) bool
// Wordy, but clarifies how this is different from readOnlyCodecs.Names().
CommaSeparatedNames() string
}
func newReadOnlyCompressionPools(
nameToPool map[string]*compressionPool,
reversedNames []string,
) readOnlyCompressionPools {
// Client and handler configs keep compression names in registration order,
// but we want the last registered to be the most preferred.
names := make([]string, 0, len(reversedNames))
seen := make(map[string]struct{}, len(reversedNames))
for i := len(reversedNames) - 1; i >= 0; i-- {
name := reversedNames[i]
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return &namedCompressionPools{
nameToPool: nameToPool,
commaSeparatedNames: strings.Join(names, ","),
}
}
type namedCompressionPools struct {
nameToPool map[string]*compressionPool
commaSeparatedNames string
}
func (m *namedCompressionPools) Get(name string) *compressionPool {
if name == "" || name == compressionIdentity {
return nil
}
return m.nameToPool[name]
}
func (m *namedCompressionPools) Contains(name string) bool {
_, ok := m.nameToPool[name]
return ok
}
func (m *namedCompressionPools) CommaSeparatedNames() string {
return m.commaSeparatedNames
}
+482
View File
@@ -0,0 +1,482 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package connect is a slim RPC framework built on Protocol Buffers and
// [net/http]. In addition to supporting its own protocol, Connect handlers and
// clients are wire-compatible with gRPC and gRPC-Web, including streaming.
//
// This documentation is intended to explain each type and function in
// isolation. Walkthroughs, FAQs, and other narrative docs are available on the
// [Connect website], and there's a working [demonstration service] on Github.
//
// [Connect website]: https://connectrpc.com
// [demonstration service]: https://github.com/connectrpc/examples-go
package connect
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
)
// Version is the semantic version of the connect module.
const Version = "1.19.1"
// These constants are used in compile-time handshakes with connect's generated
// code.
const (
IsAtLeastVersion0_0_1 = true
IsAtLeastVersion0_1_0 = true
IsAtLeastVersion1_7_0 = true
IsAtLeastVersion1_13_0 = true
)
// StreamType describes whether the client, server, neither, or both is
// streaming.
type StreamType uint8
const (
StreamTypeUnary StreamType = 0b00
StreamTypeClient StreamType = 0b01
StreamTypeServer StreamType = 0b10
StreamTypeBidi = StreamTypeClient | StreamTypeServer
)
func (s StreamType) String() string {
switch s {
case StreamTypeUnary:
return "unary"
case StreamTypeClient:
return "client"
case StreamTypeServer:
return "server"
case StreamTypeBidi:
return "bidi"
}
return fmt.Sprintf("stream_%d", s)
}
// StreamingHandlerConn is the server's view of a bidirectional message
// exchange. Interceptors for streaming RPCs may wrap StreamingHandlerConns.
//
// Like the standard library's [http.ResponseWriter], StreamingHandlerConns write
// response headers to the network with the first call to Send. Any subsequent
// mutations are effectively no-ops. Handlers may mutate response trailers at
// any time before returning. When the client has finished sending data,
// Receive returns an error wrapping [io.EOF]. Handlers should check for this
// using the standard library's [errors.Is].
//
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
// use by the gRPC and Connect protocols: applications may read them but
// shouldn't write them.
//
// StreamingHandlerConn implementations provided by this module guarantee that
// all returned errors can be cast to [*Error] using the standard library's
// [errors.As].
//
// StreamingHandlerConn implementations do not need to be safe for concurrent use.
type StreamingHandlerConn interface {
Spec() Spec
Peer() Peer
Receive(any) error
RequestHeader() http.Header
Send(any) error
ResponseHeader() http.Header
ResponseTrailer() http.Header
}
// StreamingClientConn is the client's view of a bidirectional message exchange.
// Interceptors for streaming RPCs may wrap StreamingClientConns.
//
// StreamingClientConns write request headers to the network with the first
// call to Send. Any subsequent mutations are effectively no-ops. When the
// server is done sending data, the StreamingClientConn's Receive method
// returns an error wrapping [io.EOF]. Clients should check for this using the
// standard library's [errors.Is]. If the server encounters an error during
// processing, subsequent calls to the StreamingClientConn's Send method will
// return an error wrapping [io.EOF]; clients may then call Receive to unmarshal
// the error.
//
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
// use by the gRPC and Connect protocols: applications may read them but
// shouldn't write them.
//
// StreamingClientConn implementations provided by this module guarantee that
// all returned errors can be cast to [*Error] using the standard library's
// [errors.As].
//
// In order to support bidirectional streaming RPCs, all StreamingClientConn
// implementations must support limited concurrent use. See the comments on
// each group of methods for details.
type StreamingClientConn interface {
// Spec and Peer must be safe to call concurrently with all other methods.
Spec() Spec
Peer() Peer
// Send, RequestHeader, and CloseRequest may race with each other, but must
// be safe to call concurrently with all other methods.
Send(any) error
RequestHeader() http.Header
CloseRequest() error
// Receive, ResponseHeader, ResponseTrailer, and CloseResponse may race with
// each other, but must be safe to call concurrently with all other methods.
Receive(any) error
ResponseHeader() http.Header
ResponseTrailer() http.Header
CloseResponse() error
}
// Request is a wrapper around a generated request message. It provides
// access to metadata like headers and the RPC specification, as well as
// strongly-typed access to the message itself.
type Request[T any] struct {
Msg *T
spec Spec
peer Peer
header http.Header
method string
}
// NewRequest wraps a generated request message.
func NewRequest[T any](message *T) *Request[T] {
return &Request[T]{
Msg: message,
// Initialized lazily so we don't allocate unnecessarily.
header: nil,
}
}
// Any returns the concrete request message as an empty interface, so that
// *Request implements the [AnyRequest] interface.
func (r *Request[_]) Any() any {
return r.Msg
}
// Spec returns a description of this RPC.
func (r *Request[_]) Spec() Spec {
return r.spec
}
// Peer describes the other party for this RPC.
func (r *Request[_]) Peer() Peer {
return r.peer
}
// Header returns the HTTP headers for this request. Headers beginning with
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
// protocols: applications may read them but shouldn't write them.
func (r *Request[_]) Header() http.Header {
if r.header == nil {
r.header = make(http.Header)
}
return r.header
}
// HTTPMethod returns the HTTP method for this request. This is nearly always
// POST, but side-effect-free unary RPCs could be made via a GET.
//
// On a newly created request, via NewRequest, this will return the empty
// string until the actual request is actually sent and the HTTP method
// determined. This means that client interceptor functions will see the
// empty string until *after* they delegate to the handler they wrapped. It
// is even possible for this to return the empty string after such delegation,
// if the request was never actually sent to the server (and thus no
// determination ever made about the HTTP method).
func (r *Request[_]) HTTPMethod() string {
return r.method
}
// internalOnly implements AnyRequest.
func (r *Request[_]) internalOnly() {}
// setRequestMethod sets the request method to the given value.
func (r *Request[_]) setRequestMethod(method string) {
r.method = method
}
// AnyRequest is the common method set of every [Request], regardless of type
// parameter. It's used in unary interceptors.
//
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
// use by the gRPC and Connect protocols: applications may read them but
// shouldn't write them.
//
// To preserve our ability to add methods to this interface without breaking
// backward compatibility, only types defined in this package can implement
// AnyRequest.
type AnyRequest interface {
Any() any
Spec() Spec
Peer() Peer
Header() http.Header
HTTPMethod() string
internalOnly()
setRequestMethod(string)
}
// Response is a wrapper around a generated response message. It provides
// access to metadata like headers and trailers, as well as strongly-typed
// access to the message itself.
type Response[T any] struct {
Msg *T
header http.Header
trailer http.Header
}
// NewResponse wraps a generated response message.
func NewResponse[T any](message *T) *Response[T] {
return &Response[T]{
Msg: message,
// Initialized lazily so we don't allocate unnecessarily.
header: nil,
trailer: nil,
}
}
// Any returns the concrete response message as an empty interface, so that
// *Response implements the [AnyResponse] interface.
func (r *Response[_]) Any() any {
return r.Msg
}
// Header returns the HTTP headers for this response. Headers beginning with
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
// protocols: applications may read them but shouldn't write them.
func (r *Response[_]) Header() http.Header {
if r.header == nil {
r.header = make(http.Header)
}
return r.header
}
// Trailer returns the trailers for this response. Depending on the underlying
// RPC protocol, trailers may be sent as HTTP trailers or a protocol-specific
// block of in-body metadata.
//
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols: applications may read them but shouldn't write
// them.
func (r *Response[_]) Trailer() http.Header {
if r.trailer == nil {
r.trailer = make(http.Header)
}
return r.trailer
}
// internalOnly implements AnyResponse.
func (r *Response[_]) internalOnly() {}
// AnyResponse is the common method set of every [Response], regardless of type
// parameter. It's used in unary interceptors.
//
// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for
// use by the gRPC and Connect protocols: applications may read them but
// shouldn't write them.
//
// To preserve our ability to add methods to this interface without breaking
// backward compatibility, only types defined in this package can implement
// AnyResponse.
type AnyResponse interface {
Any() any
Header() http.Header
Trailer() http.Header
internalOnly()
}
// HTTPClient is the interface connect expects HTTP clients to implement. The
// standard library's *http.Client implements HTTPClient.
type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
// Spec is a description of a client call or a handler invocation.
//
// If you're using Protobuf, protoc-gen-connect-go generates a constant for the
// fully-qualified Procedure corresponding to each RPC in your schema.
type Spec struct {
StreamType StreamType
Schema any // for protobuf RPCs, a protoreflect.MethodDescriptor
Procedure string // for example, "/acme.foo.v1.FooService/Bar"
IsClient bool // otherwise we're in a handler
IdempotencyLevel IdempotencyLevel
}
// Peer describes the other party to an RPC.
//
// When accessed client-side, Addr contains the host or host:port from the
// server's URL. When accessed server-side, Addr contains the client's address
// in IP:port format.
//
// On both the client and the server, Protocol is the RPC protocol in use.
// Currently, it's either [ProtocolConnect], [ProtocolGRPC], or
// [ProtocolGRPCWeb], but additional protocols may be added in the future.
//
// Query contains the query parameters for the request. For the server, this
// will reflect the actual query parameters sent. For the client, it is unset.
type Peer struct {
Addr string
Protocol string
Query url.Values // server-only
}
func newPeerForURL(url *url.URL, protocol string) Peer {
return Peer{
Addr: url.Host,
Protocol: protocol,
}
}
// handlerConnCloser extends StreamingHandlerConn with a method for handlers to
// terminate the message exchange (and optionally send an error to the client).
type handlerConnCloser interface {
StreamingHandlerConn
Close(error) error
}
// receiveConn represents the shared methods of both StreamingClientConn and StreamingHandlerConn
// that the below helper functions use for implementing the rules around a "unary" stream, that
// is expected to have exactly one message (or zero messages followed by a non-EOF error).
type receiveConn interface {
Spec() Spec
Receive(any) error
}
// hasHTTPMethod is implemented by streaming connections that support HTTP methods other than
// POST.
type hasHTTPMethod interface {
getHTTPMethod() string
}
// errStreamingClientConn is a sentinel error implementation of StreamingClientConn.
type errStreamingClientConn struct {
err error
}
func (c *errStreamingClientConn) Receive(msg any) error {
return c.err
}
func (c *errStreamingClientConn) Spec() Spec {
return Spec{}
}
func (c *errStreamingClientConn) Peer() Peer {
return Peer{}
}
func (c *errStreamingClientConn) Send(msg any) error {
return c.err
}
func (c *errStreamingClientConn) CloseRequest() error {
return c.err
}
func (c *errStreamingClientConn) CloseResponse() error {
return c.err
}
func (c *errStreamingClientConn) RequestHeader() http.Header {
return make(http.Header)
}
func (c *errStreamingClientConn) ResponseHeader() http.Header {
return make(http.Header)
}
func (c *errStreamingClientConn) ResponseTrailer() http.Header {
return make(http.Header)
}
// receiveUnaryResponse unmarshals a message from a StreamingClientConn, then
// envelopes the message and attaches headers and trailers. It attempts to
// consume the response stream and isn't appropriate when receiving multiple
// messages.
func receiveUnaryResponse[T any](conn StreamingClientConn, initializer maybeInitializer) (*Response[T], error) {
msg, err := receiveUnaryMessage[T](conn, initializer, "response")
if err != nil {
return nil, err
}
return &Response[T]{
Msg: msg,
header: conn.ResponseHeader(),
trailer: conn.ResponseTrailer(),
}, nil
}
// receiveUnaryRequest unmarshals a message from a StreamingClientConn, then
// envelopes the message and attaches headers and other request properties. It
// attempts to consume the request stream and isn't appropriate when receiving
// multiple messages.
func receiveUnaryRequest[T any](conn StreamingHandlerConn, initializer maybeInitializer) (*Request[T], error) {
msg, err := receiveUnaryMessage[T](conn, initializer, "request")
if err != nil {
return nil, err
}
method := http.MethodPost
if hasRequestMethod, ok := conn.(hasHTTPMethod); ok {
method = hasRequestMethod.getHTTPMethod()
}
return &Request[T]{
Msg: msg,
spec: conn.Spec(),
peer: conn.Peer(),
header: conn.RequestHeader(),
method: method,
}, nil
}
func receiveUnaryMessage[T any](conn receiveConn, initializer maybeInitializer, what string) (*T, error) {
var msg T
if err := initializer.maybe(conn.Spec(), &msg); err != nil {
return nil, err
}
// Possibly counter-intuitive, but the gRPC specs about error codes state that both clients
// and servers should return "unimplemented" when they encounter a cardinality violation: where
// the number of messages in the stream is wrong. Search for "cardinality violation" in the
// following docs:
// https://grpc.github.io/grpc/core/md_doc_statuscodes.html
if err := conn.Receive(&msg); err != nil {
if errors.Is(err, io.EOF) {
err = NewError(CodeUnimplemented, fmt.Errorf("unary %s has zero messages", what))
}
return nil, err
}
// In a well-formed stream, the one message must be the only content in the body.
// To verify that it is well-formed, try to read another message from the stream.
// TODO: optimize this second receive: ideally do it w/out allocation, w/out
// fully reading next message (if one is present), and w/out trying to
// actually unmarshal the bytes)
var msg2 T
if err := initializer.maybe(conn.Spec(), &msg2); err != nil {
return nil, err
}
if err := conn.Receive(&msg2); !errors.Is(err, io.EOF) {
if err == nil {
err = NewError(CodeUnimplemented, fmt.Errorf("unary %s has multiple messages", what))
}
return nil, err
}
return &msg, nil
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"net/http"
)
// CallInfo represents information relevant to an RPC call.
type CallInfo interface {
// Spec returns a description of this call.
Spec() Spec
// Peer describes the other party for this call.
Peer() Peer
// RequestHeader returns the HTTP headers for this request. Headers beginning with
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
// protocols: applications may read them but shouldn't write them.
RequestHeader() http.Header
// ResponseHeader returns the HTTP headers for this response. Headers beginning with
// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC
// protocols: applications may read them but shouldn't write them.
// On the client side, this method returns nil before
// the call is actually made. After the call is made, for streaming operations,
// this method will block for the server to actually return response headers.
ResponseHeader() http.Header
// ResponseTrailer returns the trailers for this response. Depending on the underlying
// RPC protocol, trailers may be sent as HTTP trailers or a protocol-specific
// block of in-body metadata.
//
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols: applications may read them but shouldn't write
// them.
//
// On the client side, this method returns nil before the call is actually made.
// After the call is made, for streaming operations, this method will block
// for the server to actually return response trailers.
ResponseTrailer() http.Header
// HTTPMethod returns the HTTP method for this request. This is nearly always
// POST, but side-effect-free unary RPCs could be made via a GET.
//
// On a newly created request, via NewRequest, this will return the empty
// string until the actual request is actually sent and the HTTP method
// determined. This means that client interceptor functions will see the
// empty string until *after* they delegate to the handler they wrapped. It
// is even possible for this to return the empty string after such delegation,
// if the request was never actually sent to the server (and thus no
// determination ever made about the HTTP method).
HTTPMethod() string
internalOnly()
}
// Create a new client (i.e. outgoing) context for use from a client. When the
// returned context is passed to RPCs, the returned call info can be used to set
// request metadata before the RPC is invoked and to inspect response
// metadata after the RPC completes.
//
// The returned context may be re-used across RPCs as long as they are
// not concurrent. Results of all CallInfo methods other than
// RequestHeader() are undefined if the context is used with concurrent RPCs.
func NewClientContext(ctx context.Context) (context.Context, CallInfo) {
info := &clientCallInfo{}
return context.WithValue(ctx, clientCallInfoContextKey{}, info), info
}
// CallInfoForHandlerContext returns the CallInfo for the given handler (i.e. incoming) context, if there is one.
func CallInfoForHandlerContext(ctx context.Context) (CallInfo, bool) {
value, ok := ctx.Value(handlerCallInfoContextKey{}).(CallInfo)
return value, ok
}
// handlerCallInfo is a CallInfo implementation used for unary handlers.
type handlerCallInfo struct {
spec Spec
peer Peer
method string
requestHeader http.Header
responseHeader http.Header
responseTrailer http.Header
}
func (c *handlerCallInfo) Spec() Spec {
return c.spec
}
func (c *handlerCallInfo) Peer() Peer {
return c.peer
}
func (c *handlerCallInfo) RequestHeader() http.Header {
if c.requestHeader == nil {
c.requestHeader = make(http.Header)
}
return c.requestHeader
}
func (c *handlerCallInfo) ResponseHeader() http.Header {
if c.responseHeader == nil {
c.responseHeader = make(http.Header)
}
return c.responseHeader
}
func (c *handlerCallInfo) ResponseTrailer() http.Header {
if c.responseTrailer == nil {
c.responseTrailer = make(http.Header)
}
return c.responseTrailer
}
func (c *handlerCallInfo) HTTPMethod() string {
return c.method
}
// internalOnly implements CallInfo.
func (c *handlerCallInfo) internalOnly() {}
// streamingHandlerCallInfo is a CallInfo implementation used for streaming RPC handlers.
type streamingHandlerCallInfo struct {
conn StreamingHandlerConn
}
func (c *streamingHandlerCallInfo) Spec() Spec {
return c.conn.Spec()
}
func (c *streamingHandlerCallInfo) Peer() Peer {
return c.conn.Peer()
}
func (c *streamingHandlerCallInfo) RequestHeader() http.Header {
return c.conn.RequestHeader()
}
func (c *streamingHandlerCallInfo) ResponseHeader() http.Header {
return c.conn.ResponseHeader()
}
func (c *streamingHandlerCallInfo) ResponseTrailer() http.Header {
return c.conn.ResponseTrailer()
}
func (c *streamingHandlerCallInfo) HTTPMethod() string {
// All stream calls are POSTs
return http.MethodPost
}
// internalOnly implements CallInfo.
func (c *streamingHandlerCallInfo) internalOnly() {}
// clientCallInfo is a CallInfo implementation used for clients.
type clientCallInfo struct {
responseSource
spec Spec
peer Peer
method string
requestHeader http.Header
}
func (c *clientCallInfo) Spec() Spec {
return c.spec
}
func (c *clientCallInfo) Peer() Peer {
return c.peer
}
func (c *clientCallInfo) RequestHeader() http.Header {
if c.requestHeader == nil {
c.requestHeader = make(http.Header)
}
return c.requestHeader
}
func (c *clientCallInfo) ResponseHeader() http.Header {
if c.responseSource == nil {
return nil
}
return c.responseSource.ResponseHeader()
}
func (c *clientCallInfo) ResponseTrailer() http.Header {
if c.responseSource == nil {
return nil
}
return c.responseSource.ResponseTrailer()
}
func (c *clientCallInfo) HTTPMethod() string {
return c.method
}
// internalOnly implements CallInfo.
func (c *clientCallInfo) internalOnly() {}
// clientCallInfoContextKey is the key used to store client call info in context.
type clientCallInfoContextKey struct{}
// sentinelContextKey is the key used to store a copy of client call info in context
// when a request is made.
// Each step in an interceptor chain compares the actual call info with the
// sentinel call info. If the two values are different, the request will
// return an error in the interceptor.
// This protects against changing the call info in interceptors, which is prohibited
// as it would allow users to modify call info mid-flight independent of the actual
// request or response.
// Users who wish to modify call info data such as headers and trailers should instead
// use Connect [Request] and [Response] wrapper types.
type sentinelContextKey struct{}
// handlerCallInfoContextKey is the key used to store handler call info in context.
type handlerCallInfoContextKey struct{}
// responseSource indicates a type that manages response headers and trailers.
type responseSource interface {
ResponseHeader() http.Header
ResponseTrailer() http.Header
}
// clientCallInfoForContext gets the call info from a client/outgoing context.
func clientCallInfoForContext(ctx context.Context) (*clientCallInfo, bool) {
info, ok := ctx.Value(clientCallInfoContextKey{}).(*clientCallInfo)
return info, ok
}
// newHandlerContext creates a new handler/incoming context.
func newHandlerContext(ctx context.Context, info CallInfo) context.Context {
return context.WithValue(ctx, handlerCallInfoContextKey{}, info)
}
+471
View File
@@ -0,0 +1,471 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"sync"
"sync/atomic"
)
// duplexHTTPCall is a full-duplex stream between the client and server. The
// request body is the stream from client to server, and the response body is
// the reverse.
//
// Be warned: we need to use some lesser-known APIs to do this with net/http.
type duplexHTTPCall struct {
ctx context.Context
httpClient HTTPClient
streamType StreamType
onRequestSend func(*http.Request)
validateResponse func(*http.Response) *Error
// io.Pipe is used to implement the request body for client streaming calls.
// If the request is unary, requestBodyWriter is nil.
requestBodyWriter *io.PipeWriter
// requestSent ensures we only send the request once.
requestSent atomic.Bool
request *http.Request
// responseReady is closed when the response is ready or when the request
// fails. Any error on request initialisation will be set on the
// responseErr. There's always a response if responseErr is nil.
responseReady chan struct{}
response *http.Response
responseErr error
}
func newDuplexHTTPCall(
ctx context.Context,
httpClient HTTPClient,
url *url.URL,
spec Spec,
header http.Header,
) *duplexHTTPCall {
// ensure we make a copy of the url before we pass along to the
// Request. This ensures if a transport out of our control wants
// to mutate the req.URL, we don't feel the effects of it.
url = cloneURL(url)
// This is mirroring what http.NewRequestContext did, but
// using an already parsed url.URL object, rather than a string
// and parsing it again. This is a bit funny with HTTP/1.1
// explicitly, but this is logic copied over from
// NewRequestContext and doesn't effect the actual version
// being transmitted.
request := (&http.Request{
Method: http.MethodPost,
URL: url,
Header: header,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Body: http.NoBody,
GetBody: getNoBody,
Host: url.Host,
}).WithContext(ctx)
return &duplexHTTPCall{
ctx: ctx,
httpClient: httpClient,
streamType: spec.StreamType,
request: request,
responseReady: make(chan struct{}),
}
}
// Send sends a message to the server.
func (d *duplexHTTPCall) Send(payload messagePayload) (int64, error) {
if d.streamType&StreamTypeClient == 0 {
return d.sendUnary(payload)
}
isFirst := d.requestSent.CompareAndSwap(false, true)
if isFirst {
// This is the first time we're sending a message to the server.
// We need to send the request headers and start the request.
pipeReader, pipeWriter := io.Pipe()
d.requestBodyWriter = pipeWriter
d.request.Body = pipeReader
d.request.GetBody = nil // GetBody not supported for client streaming
d.request.ContentLength = -1
go d.makeRequest() // concurrent request
}
if err := d.ctx.Err(); err != nil {
return 0, wrapIfContextError(err)
}
if isFirst && payload.Len() == 0 {
// On first write a nil Send is used to send request headers. Avoid
// writing a zero-length payload to avoid superfluous errors with close.
return 0, nil
}
// It's safe to write to this side of the pipe while net/http concurrently
// reads from the other side.
bytesWritten, err := payload.WriteTo(d.requestBodyWriter)
if err != nil && errors.Is(err, io.ErrClosedPipe) {
// Signal that the stream is closed with the more-typical io.EOF instead of
// io.ErrClosedPipe. This makes it easier for protocol-specific wrappers to
// match grpc-go's behavior.
err = io.EOF
}
return bytesWritten, err
}
func (d *duplexHTTPCall) sendUnary(payload messagePayload) (int64, error) {
// Unary messages are sent as a single HTTP request. We don't need to use a
// pipe for the request body and we don't need to send headers separately.
if !d.requestSent.CompareAndSwap(false, true) {
return 0, errors.New("request already sent")
}
payloadLength := int64(payload.Len())
if payloadLength > 0 {
// Build the request body from the payload.
payloadBody := newPayloadCloser(payload)
d.request.Body = payloadBody
d.request.ContentLength = payloadLength
d.request.GetBody = func() (io.ReadCloser, error) {
if !payloadBody.Rewind() {
return nil, errors.New("payload cannot be retried")
}
return payloadBody, nil
}
// Release the payload ensuring that after Send returns the
// payload is safe to be reused. See [http.RoundTripper] for
// more details.
defer payloadBody.Release()
}
d.makeRequest() // synchronous request
if d.responseErr != nil {
// Check on response errors for context errors. Other errors are
// handled on read.
if err := d.ctx.Err(); err != nil {
return 0, wrapIfContextError(err)
}
}
return payloadLength, nil
}
// CloseWrite closes the request body. Callers *must* call CloseWrite before Read when
// using HTTP/1.x.
func (d *duplexHTTPCall) CloseWrite() error {
// Even if Write was never called, we need to make an HTTP request. This
// ensures that we've sent any headers to the server and that we have an HTTP
// response to read from.
if d.requestSent.CompareAndSwap(false, true) {
go d.makeRequest()
// We never setup a request body, so it's effectively already closed.
// So nothing else to do.
return nil
}
// The user calls CloseWrite to indicate that they're done sending data. It's
// safe to close the write side of the pipe while net/http is reading from
// it.
//
// Because connect also supports some RPC types over HTTP/1.1, we need to be
// careful how we expose this method to users. HTTP/1.1 doesn't support
// bidirectional streaming - the write side of the stream (aka request body)
// must be closed before we start reading the response or we'll just block
// forever. To make sure users don't have to worry about this, the generated
// code for unary, client streaming, and server streaming RPCs must call
// CloseWrite automatically rather than requiring the user to do it.
if d.requestBodyWriter != nil {
return d.requestBodyWriter.Close()
}
return d.request.Body.Close()
}
// Header returns the HTTP request headers.
func (d *duplexHTTPCall) Header() http.Header {
return d.request.Header
}
// Trailer returns the HTTP request trailers.
func (d *duplexHTTPCall) Trailer() http.Header {
return d.request.Trailer
}
// URL returns the URL for the request.
func (d *duplexHTTPCall) URL() *url.URL {
return d.request.URL
}
// Method returns the HTTP method for the request (GET or POST).
func (d *duplexHTTPCall) Method() string {
return d.request.Method
}
// SetMethod changes the method of the request before it is sent.
func (d *duplexHTTPCall) SetMethod(method string) {
d.request.Method = method
}
// Read from the response body. Returns the first error passed to SetError.
func (d *duplexHTTPCall) Read(data []byte) (int, error) {
// First, we wait until we've gotten the response headers and established the
// server-to-client side of the stream.
if err := d.BlockUntilResponseReady(); err != nil {
// The stream is already closed or corrupted.
return 0, err
}
// Before we read, check if the context has been canceled.
if err := d.ctx.Err(); err != nil {
return 0, wrapIfContextError(err)
}
n, err := d.response.Body.Read(data)
if err != nil && !errors.Is(err, io.EOF) {
err = wrapIfContextDone(d.ctx, err)
err = wrapIfRSTError(err)
}
return n, err
}
func (d *duplexHTTPCall) CloseRead() error {
_ = d.BlockUntilResponseReady()
if d.response == nil {
return nil
}
err := d.response.Body.Close()
err = wrapIfContextDone(d.ctx, err)
return wrapIfRSTError(err)
}
// ResponseStatusCode is the response's HTTP status code.
func (d *duplexHTTPCall) ResponseStatusCode() (int, error) {
if err := d.BlockUntilResponseReady(); err != nil {
return 0, err
}
return d.response.StatusCode, nil
}
// ResponseHeader returns the response HTTP headers.
func (d *duplexHTTPCall) ResponseHeader() http.Header {
_ = d.BlockUntilResponseReady()
if d.response != nil {
return d.response.Header
}
return make(http.Header)
}
// ResponseTrailer returns the response HTTP trailers.
func (d *duplexHTTPCall) ResponseTrailer() http.Header {
_ = d.BlockUntilResponseReady()
if d.response != nil {
return d.response.Trailer
}
return make(http.Header)
}
// SetValidateResponse sets the response validation function. The function runs
// in a background goroutine.
func (d *duplexHTTPCall) SetValidateResponse(validate func(*http.Response) *Error) {
d.validateResponse = validate
}
// BlockUntilResponseReady returns when the response is ready or reports an
// error from initializing the request.
func (d *duplexHTTPCall) BlockUntilResponseReady() error {
<-d.responseReady
return d.responseErr
}
func (d *duplexHTTPCall) makeRequest() {
// This runs concurrently with Write and CloseWrite. Read and CloseRead wait
// on d.responseReady, so we can't race with them.
defer close(d.responseReady)
// Promote the header Host to the request object.
if host := getHeaderCanonical(d.request.Header, headerHost); len(host) > 0 {
d.request.Host = host
}
if d.onRequestSend != nil {
d.onRequestSend(d.request)
}
// Once we send a message to the server, they send a message back and
// establish the receive side of the stream.
// On error, we close the request body using the Write side of the pipe.
// This ensures HTTP2 streams receive an io.EOF from the Read side of the
// pipe. Write's check for io.ErrClosedPipe and will convert this to io.EOF.
response, err := d.httpClient.Do(d.request) //nolint:bodyclose
if err != nil {
if errors.Is(err, io.EOF) {
// We use io.EOF as a sentinel in many places and don't want this
// transport error to be confused for those other situations.
err = io.ErrUnexpectedEOF
}
err = wrapIfContextError(err)
err = wrapIfLikelyH2CNotConfiguredError(d.request, err)
err = wrapIfLikelyWithGRPCNotUsedError(err)
err = wrapIfRSTError(err)
if _, ok := asError(err); !ok {
err = NewError(CodeUnavailable, err)
}
d.responseErr = err
_ = d.CloseWrite()
return
}
// We've got a response. We can now read from the response body.
// Closing the response body is delegated to the caller even on error.
d.response = response
if err := d.validateResponse(response); err != nil {
d.responseErr = err
_ = d.CloseWrite()
return
}
if (d.streamType&StreamTypeBidi) == StreamTypeBidi && response.ProtoMajor < 2 {
// If we somehow dialed an HTTP/1.x server, fail with an explicit message
// rather than returning a more cryptic error later on.
d.responseErr = errorf(
CodeUnimplemented,
"response from %v is HTTP/%d.%d: bidi streams require at least HTTP/2",
d.request.URL,
response.ProtoMajor,
response.ProtoMinor,
)
_ = d.CloseWrite()
}
}
// getNoBody is a GetBody function for http.NoBody.
func getNoBody() (io.ReadCloser, error) {
return http.NoBody, nil
}
// messagePayload is a sized and seekable message payload. The interface is
// implemented by [*bytes.Reader] and *envelope. Reads must be non-blocking.
type messagePayload interface {
io.Reader
io.WriterTo
io.Seeker
Len() int
}
// nopPayload is a message payload that does nothing. It's used to send headers
// to the server.
type nopPayload struct{}
var _ messagePayload = nopPayload{}
func (nopPayload) Read([]byte) (int, error) {
return 0, io.EOF
}
func (nopPayload) WriteTo(io.Writer) (int64, error) {
return 0, nil
}
func (nopPayload) Seek(int64, int) (int64, error) {
return 0, nil
}
func (nopPayload) Len() int {
return 0
}
// messageSender sends a message payload. The interface is implemented by
// [*duplexHTTPCall] and writeSender.
type messageSender interface {
Send(messagePayload) (int64, error)
}
// writeSender is a sender that writes to an [io.Writer]. Useful for wrapping
// [http.ResponseWriter].
type writeSender struct {
writer io.Writer
}
var _ messageSender = writeSender{}
func (w writeSender) Send(payload messagePayload) (int64, error) {
return payload.WriteTo(w.writer)
}
// See: https://cs.opensource.google/go/go/+/refs/tags/go1.20.1:src/net/http/clone.go;l=22-33
func cloneURL(oldURL *url.URL) *url.URL {
if oldURL == nil {
return nil
}
newURL := new(url.URL)
*newURL = *oldURL
if oldURL.User != nil {
newURL.User = new(url.Userinfo)
*newURL.User = *oldURL.User
}
return newURL
}
// payloadCloser is an [io.ReadCloser] that wraps a messagePayload. It's used to
// implement the request body for unary calls. To safely reuse the buffer
// call Release after the response is received to ensure the payload is safe for
// reuse.
type payloadCloser struct {
mu sync.Mutex
payload messagePayload // nil after Release
}
func newPayloadCloser(payload messagePayload) *payloadCloser {
return &payloadCloser{
payload: payload,
}
}
// Read implements [io.Reader].
func (p *payloadCloser) Read(dst []byte) (readN int, err error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.payload == nil {
return 0, io.EOF
}
return p.payload.Read(dst)
}
// WriteTo implements [io.WriterTo].
func (p *payloadCloser) WriteTo(dst io.Writer) (int64, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.payload == nil {
return 0, nil
}
return p.payload.WriteTo(dst)
}
// Close implements [io.Closer].
func (p *payloadCloser) Close() error {
return nil
}
// Rewind rewinds the payload to the beginning. It returns false if the
// payload has been discarded from a previous call to Release.
func (p *payloadCloser) Rewind() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.payload == nil {
return false
}
if _, err := p.payload.Seek(0, io.SeekStart); err != nil {
return false
}
return true
}
// Release discards the payload. After Release is called, the payload cannot be
// rewound and the payload is safe to reuse.
func (p *payloadCloser) Release() {
p.mu.Lock()
p.payload = nil
p.mu.Unlock()
}
+387
View File
@@ -0,0 +1,387 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
)
// flagEnvelopeCompressed indicates that the data is compressed. It has the
// same meaning in the gRPC-Web, gRPC-HTTP2, and Connect protocols.
const flagEnvelopeCompressed = 0b00000001
var errSpecialEnvelope = errorf(
CodeUnknown,
"final message has protocol-specific flags: %w",
// User code checks for end of stream with errors.Is(err, io.EOF).
io.EOF,
)
// envelope is a block of arbitrary bytes wrapped in gRPC and Connect's framing
// protocol.
//
// Each message is preceded by a 5-byte prefix. The first byte is a uint8 used
// as a set of bitwise flags, and the remainder is a uint32 indicating the
// message length. gRPC and Connect interpret the bitwise flags differently, so
// envelope leaves their interpretation up to the caller.
type envelope struct {
Data *bytes.Buffer
Flags uint8
offset int64
}
var _ messagePayload = (*envelope)(nil)
func (e *envelope) IsSet(flag uint8) bool {
return e.Flags&flag == flag
}
// Read implements [io.Reader].
func (e *envelope) Read(data []byte) (readN int, err error) {
if e.offset < 5 {
prefix, err := makeEnvelopePrefix(e.Flags, e.Data.Len())
if err != nil {
return 0, err
}
readN = copy(data, prefix[e.offset:])
e.offset += int64(readN)
if e.offset < 5 {
return readN, nil
}
data = data[readN:]
}
n := copy(data, e.Data.Bytes()[e.offset-5:])
e.offset += int64(n)
readN += n
if readN == 0 && e.offset == int64(e.Data.Len()+5) {
err = io.EOF
}
return readN, err
}
// WriteTo implements [io.WriterTo].
func (e *envelope) WriteTo(dst io.Writer) (wroteN int64, err error) {
if e.offset < 5 {
prefix, err := makeEnvelopePrefix(e.Flags, e.Data.Len())
if err != nil {
return 0, err
}
prefixN, err := dst.Write(prefix[e.offset:])
e.offset += int64(prefixN)
wroteN += int64(prefixN)
if e.offset < 5 {
return wroteN, err
}
}
n, err := dst.Write(e.Data.Bytes()[e.offset-5:])
e.offset += int64(n)
wroteN += int64(n)
return wroteN, err
}
// Seek implements [io.Seeker]. Based on the implementation of [bytes.Reader].
func (e *envelope) Seek(offset int64, whence int) (int64, error) {
var abs int64
switch whence {
case io.SeekStart:
abs = offset
case io.SeekCurrent:
abs = e.offset + offset
case io.SeekEnd:
abs = int64(e.Data.Len()) + offset
default:
return 0, errors.New("connect.envelope.Seek: invalid whence")
}
if abs < 0 {
return 0, errors.New("connect.envelope.Seek: negative position")
}
e.offset = abs
return abs, nil
}
// Len returns the number of bytes of the unread portion of the envelope.
func (e *envelope) Len() int {
if length := int(int64(e.Data.Len()) + 5 - e.offset); length > 0 {
return length
}
return 0
}
type envelopeWriter struct {
ctx context.Context //nolint:containedctx
sender messageSender
codec Codec
compressMinBytes int
compressionPool *compressionPool
bufferPool *bufferPool
sendMaxBytes int
}
func (w *envelopeWriter) Marshal(message any) *Error {
if message == nil {
// Send no-op message to create the request and send headers.
payload := nopPayload{}
if _, err := w.sender.Send(payload); err != nil {
if connectErr, ok := asError(err); ok {
return connectErr
}
return NewError(CodeUnknown, err)
}
return nil
}
if appender, ok := w.codec.(marshalAppender); ok {
return w.marshalAppend(message, appender)
}
return w.marshal(message)
}
// Write writes the enveloped message, compressing as necessary. It doesn't
// retain any references to the supplied envelope or its underlying data.
func (w *envelopeWriter) Write(env *envelope) *Error {
if env.IsSet(flagEnvelopeCompressed) ||
w.compressionPool == nil ||
env.Data.Len() < w.compressMinBytes {
if w.sendMaxBytes > 0 && env.Data.Len() > w.sendMaxBytes {
return errorf(CodeResourceExhausted, "message size %d exceeds sendMaxBytes %d", env.Data.Len(), w.sendMaxBytes)
}
return w.write(env)
}
data := w.bufferPool.Get()
defer w.bufferPool.Put(data)
if err := w.compressionPool.Compress(data, env.Data); err != nil {
return err
}
if w.sendMaxBytes > 0 && data.Len() > w.sendMaxBytes {
return errorf(CodeResourceExhausted, "compressed message size %d exceeds sendMaxBytes %d", data.Len(), w.sendMaxBytes)
}
return w.write(&envelope{
Data: data,
Flags: env.Flags | flagEnvelopeCompressed,
})
}
func (w *envelopeWriter) marshalAppend(message any, codec marshalAppender) *Error {
// Codec supports MarshalAppend; try to re-use a []byte from the pool.
buffer := w.bufferPool.Get()
defer w.bufferPool.Put(buffer)
raw, err := codec.MarshalAppend(buffer.Bytes(), message)
if err != nil {
return errorf(CodeInternal, "marshal message: %w", err)
}
if cap(raw) > buffer.Cap() {
// The buffer from the pool was too small, so MarshalAppend grew the slice.
// Pessimistically assume that the too-small buffer is insufficient for the
// application workload, so there's no point in keeping it in the pool.
// Instead, replace it with the larger, newly-allocated slice. This
// allocates, but it's a small, constant-size allocation.
*buffer = *bytes.NewBuffer(raw)
} else {
// MarshalAppend didn't allocate, but we need to fix the internal state of
// the buffer. Compared to replacing the buffer (as above), buffer.Write
// copies but avoids allocating.
buffer.Write(raw)
}
envelope := &envelope{Data: buffer}
return w.Write(envelope)
}
func (w *envelopeWriter) marshal(message any) *Error {
// Codec doesn't support MarshalAppend; let Marshal allocate a []byte.
raw, err := w.codec.Marshal(message)
if err != nil {
return errorf(CodeInternal, "marshal message: %w", err)
}
buffer := bytes.NewBuffer(raw)
// Put our new []byte into the pool for later reuse.
defer w.bufferPool.Put(buffer)
envelope := &envelope{Data: buffer}
return w.Write(envelope)
}
func (w *envelopeWriter) write(env *envelope) *Error {
if _, err := w.sender.Send(env); err != nil {
err = wrapIfContextDone(w.ctx, err)
if connectErr, ok := asError(err); ok {
return connectErr
}
return errorf(CodeUnknown, "write envelope: %w", err)
}
return nil
}
type envelopeReader struct {
ctx context.Context //nolint:containedctx
reader io.Reader
bytesRead int64 // detect trailers-only gRPC responses
codec Codec
last envelope
compressionPool *compressionPool
bufferPool *bufferPool
readMaxBytes int
}
func (r *envelopeReader) Unmarshal(message any) *Error {
buffer := r.bufferPool.Get()
var dontRelease *bytes.Buffer
defer func() {
if buffer != dontRelease {
r.bufferPool.Put(buffer)
}
}()
env := &envelope{Data: buffer}
err := r.Read(env)
switch {
case err == nil && env.IsSet(flagEnvelopeCompressed) && r.compressionPool == nil:
return errorf(
CodeInternal,
"protocol error: sent compressed message without compression support",
)
case err == nil &&
(env.Flags == 0 || env.Flags == flagEnvelopeCompressed) &&
env.Data.Len() == 0:
// This is a standard message (because none of the top 7 bits are set) and
// there's no data, so the zero value of the message is correct.
return nil
case err != nil && errors.Is(err, io.EOF):
// The stream has ended. Propagate the EOF to the caller.
return err
case err != nil:
// Something's wrong.
return err
}
data := env.Data
if data.Len() > 0 && env.IsSet(flagEnvelopeCompressed) {
decompressed := r.bufferPool.Get()
defer func() {
if decompressed != dontRelease {
r.bufferPool.Put(decompressed)
}
}()
if err := r.compressionPool.Decompress(decompressed, data, int64(r.readMaxBytes)); err != nil {
return err
}
data = decompressed
}
if env.Flags != 0 && env.Flags != flagEnvelopeCompressed {
// Drain the rest of the stream to ensure there is no extra data.
numBytes, err := discard(r.reader)
r.bytesRead += numBytes
if err != nil {
err = wrapIfContextError(err)
if connErr, ok := asError(err); ok {
return connErr
}
return errorf(CodeInternal, "corrupt response: I/O error after end-stream message: %w", err)
} else if numBytes > 0 {
return errorf(CodeInternal, "corrupt response: %d extra bytes after end of stream", numBytes)
}
// One of the protocol-specific flags are set, so this is the end of the
// stream. Save the message for protocol-specific code to process and
// return a sentinel error. We alias the buffer with dontRelease as a
// way of marking it so above defers don't release it to the pool.
r.last = envelope{
Data: data,
Flags: env.Flags,
}
dontRelease = data
return errSpecialEnvelope
}
if err := r.codec.Unmarshal(data.Bytes(), message); err != nil {
return errorf(CodeInvalidArgument, "unmarshal message: %w", err)
}
return nil
}
func (r *envelopeReader) Read(env *envelope) *Error {
prefixes := [5]byte{}
// io.ReadFull reads the number of bytes requested, or returns an error.
// io.EOF will only be returned if no bytes were read.
n, err := io.ReadFull(r.reader, prefixes[:])
r.bytesRead += int64(n)
if err != nil {
if errors.Is(err, io.EOF) {
// The stream ended cleanly. That's expected, but we need to propagate an EOF
// to the user so that they know that the stream has ended. We shouldn't
// add any alarming text about protocol errors, though.
return NewError(CodeUnknown, err)
}
err = wrapIfMaxBytesError(err, "read 5 byte message prefix")
err = wrapIfContextDone(r.ctx, err)
if connectErr, ok := asError(err); ok {
return connectErr
}
// Something else has gone wrong - the stream didn't end cleanly.
return errorf(
CodeInvalidArgument,
"protocol error: incomplete envelope: %w", err,
)
}
size := int64(binary.BigEndian.Uint32(prefixes[1:5]))
if r.readMaxBytes > 0 && size > int64(r.readMaxBytes) {
n, err := io.CopyN(io.Discard, r.reader, size)
r.bytesRead += n
if err != nil && !errors.Is(err, io.EOF) {
return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", r.readMaxBytes, err)
}
return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", size, r.readMaxBytes)
}
// We've read the prefix, so we know how many bytes to expect.
// CopyN will return an error if it doesn't read the requested
// number of bytes.
readN, err := io.CopyN(env.Data, r.reader, size)
r.bytesRead += readN
if err != nil {
if errors.Is(err, io.EOF) {
// We've gotten fewer bytes than we expected, so the stream has ended
// unexpectedly.
return errorf(
CodeInvalidArgument,
"protocol error: promised %d bytes in enveloped message, got %d bytes",
size,
readN,
)
}
err = wrapIfMaxBytesError(err, "read %d byte message", size)
err = wrapIfContextDone(r.ctx, err)
if connectErr, ok := asError(err); ok {
return connectErr
}
return errorf(CodeUnknown, "read enveloped message: %w", err)
}
env.Flags = prefixes[0]
return nil
}
func makeEnvelopePrefix(flags uint8, size int) ([5]byte, error) {
// Cast as 64-bit to ensure comparison works on all architectures.
size64 := int64(size)
if size64 < 0 || size64 > math.MaxUint32 {
return [5]byte{}, fmt.Errorf("connect.makeEnvelopePrefix: size %d out of bounds", size)
}
prefix := [5]byte{}
prefix[0] = flags
binary.BigEndian.PutUint32(prefix[1:5], uint32(size64))
return prefix, nil
}
+458
View File
@@ -0,0 +1,458 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
const (
commonErrorsURL = "https://connectrpc.com/docs/go/common-errors"
defaultAnyResolverPrefix = "type.googleapis.com/"
)
var (
// errNotModified signals Connect-protocol responses to GET requests to use the
// 304 Not Modified HTTP error code.
errNotModified = errors.New("not modified")
// errNotModifiedClient wraps ErrNotModified for use client-side.
errNotModifiedClient = fmt.Errorf("HTTP 304: %w", errNotModified)
)
// An ErrorDetail is a self-describing Protobuf message attached to an [*Error].
// Error details are sent over the network to clients, which can then work with
// strongly-typed data rather than trying to parse a complex error message. For
// example, you might use details to send a localized error message or retry
// parameters to the client.
//
// The [google.golang.org/genproto/googleapis/rpc/errdetails] package contains a
// variety of Protobuf messages commonly used as error details.
type ErrorDetail struct {
pbAny *anypb.Any
pbInner proto.Message // if nil, must be extracted from pbAny
wireJSON string // preserve human-readable JSON
}
// NewErrorDetail constructs a new error detail. If msg is an *[anypb.Any] then
// it is used as is. Otherwise, it is first marshalled into an *[anypb.Any]
// value. This returns an error if msg cannot be marshalled.
func NewErrorDetail(msg proto.Message) (*ErrorDetail, error) {
// If it's already an Any, don't wrap it inside another.
if pb, ok := msg.(*anypb.Any); ok {
return &ErrorDetail{pbAny: pb}, nil
}
pb, err := anypb.New(msg)
if err != nil {
return nil, err
}
return &ErrorDetail{pbAny: pb, pbInner: msg}, nil
}
// Type is the fully-qualified name of the detail's Protobuf message (for
// example, acme.foo.v1.FooDetail).
func (d *ErrorDetail) Type() string {
// proto.Any tries to make messages self-describing by using type URLs rather
// than plain type names, but there aren't any descriptor registries
// deployed. With the current state of the `Any` code, it's not possible to
// build a useful type registry either. To hide this from users, we should
// trim the URL prefix is added to the type name.
//
// If we ever want to support remote registries, we can add an explicit
// `TypeURL` method.
return typeNameForURL(d.pbAny.GetTypeUrl())
}
// Bytes returns a copy of the Protobuf-serialized detail.
func (d *ErrorDetail) Bytes() []byte {
out := make([]byte, len(d.pbAny.GetValue()))
copy(out, d.pbAny.GetValue())
return out
}
// Value uses the Protobuf runtime's package-global registry to unmarshal the
// Detail into a strongly-typed message. Typically, clients use Go type
// assertions to cast from the proto.Message interface to concrete types.
func (d *ErrorDetail) Value() (proto.Message, error) {
if d.pbInner != nil {
// We clone it so that if the caller mutates the returned value,
// they don't inadvertently corrupt this error detail value.
return proto.Clone(d.pbInner), nil
}
return d.pbAny.UnmarshalNew()
}
// An Error captures four key pieces of information: a [Code], an underlying Go
// error, a map of metadata, and an optional collection of arbitrary Protobuf
// messages called "details" (more on those below). Servers send the code, the
// underlying error's Error() output, the metadata, and details over the wire
// to clients. Remember that the underlying error's message will be sent to
// clients - take care not to leak sensitive information from public APIs!
//
// Service implementations and interceptors should return errors that can be
// cast to an [*Error] (using the standard library's [errors.As]). If the returned
// error can't be cast to an [*Error], connect will use [CodeUnknown] and the
// returned error's message.
//
// Error details are an optional mechanism for servers, interceptors, and
// proxies to attach arbitrary Protobuf messages to the error code and message.
// They're a clearer and more performant alternative to HTTP header
// microformats. See [the documentation on errors] for more details.
//
// [the documentation on errors]: https://connectrpc.com/docs/go/errors
type Error struct {
code Code
err error
details []*ErrorDetail
meta http.Header
wireErr bool
}
// NewError annotates any Go error with a status code.
func NewError(c Code, underlying error) *Error {
return &Error{code: c, err: underlying}
}
// NewWireError is similar to [NewError], but the resulting *Error returns true
// when tested with [IsWireError].
//
// This is useful for clients trying to propagate partial failures from
// streaming RPCs. Often, these RPCs include error information in their
// response messages (for example, [gRPC server reflection] and
// OpenTelemetry's [OTLP]). Clients propagating these errors up the stack
// should use NewWireError to clarify that the error code, message, and details
// (if any) were explicitly sent by the server rather than inferred from a
// lower-level networking error or timeout.
//
// [gRPC server reflection]: https://github.com/grpc/grpc/blob/v1.49.2/src/proto/grpc/reflection/v1alpha/reflection.proto#L132-L136
// [OTLP]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md#partial-success
func NewWireError(c Code, underlying error) *Error {
err := NewError(c, underlying)
err.wireErr = true
return err
}
// IsWireError checks whether the error was returned by the server, as opposed
// to being synthesized by the client.
//
// Clients may find this useful when deciding how to propagate errors. For
// example, an RPC-to-HTTP proxy might expose a server-sent CodeUnknown as an
// HTTP 500 but a client-synthesized CodeUnknown as a 503.
//
// Handlers will strip [Error.Meta] headers propagated from wire errors to avoid
// leaking response headers. To propagate headers recreate the error as a
// non-wire error.
func IsWireError(err error) bool {
se := new(Error)
if !errors.As(err, &se) {
return false
}
return se.wireErr
}
// NewNotModifiedError indicates that the requested resource hasn't changed. It
// should be used only when handlers wish to respond to conditional HTTP GET
// requests with a 304 Not Modified. In all other circumstances, including all
// RPCs using the gRPC or gRPC-Web protocols, it's equivalent to sending an
// error with [CodeUnknown]. The supplied headers should include Etag,
// Cache-Control, or any other headers required by [RFC 9110 § 15.4.5].
//
// Clients should check for this error using [IsNotModifiedError].
//
// [RFC 9110 § 15.4.5]: https://httpwg.org/specs/rfc9110.html#status.304
func NewNotModifiedError(headers http.Header) *Error {
err := NewError(CodeUnknown, errNotModified)
if headers != nil {
err.meta = headers
}
return err
}
func (e *Error) Error() string {
message := e.Message()
if message == "" {
return e.code.String()
}
return e.code.String() + ": " + message
}
// Message returns the underlying error message. It may be empty if the
// original error was created with a status code and a nil error.
func (e *Error) Message() string {
if e.err != nil {
return e.err.Error()
}
return ""
}
// Unwrap allows [errors.Is] and [errors.As] access to the underlying error.
func (e *Error) Unwrap() error {
return e.err
}
// Code returns the error's status code.
func (e *Error) Code() Code {
return e.code
}
// Details returns the error's details.
func (e *Error) Details() []*ErrorDetail {
return e.details
}
// AddDetail appends to the error's details.
func (e *Error) AddDetail(d *ErrorDetail) {
e.details = append(e.details, d)
}
// Meta allows the error to carry additional information as key-value pairs.
//
// Protocol-specific headers and trailers may be removed to avoid breaking
// protocol semantics. For example, Content-Length and Content-Type headers
// won't be propagated. See the documentation for each protocol for more
// datails.
//
// When clients receive errors, the metadata contains the union of the HTTP
// headers and the protocol-specific trailers (either HTTP trailers or in-body
// metadata).
func (e *Error) Meta() http.Header {
if e.meta == nil {
e.meta = make(http.Header)
}
return e.meta
}
func (e *Error) detailsAsAny() []*anypb.Any {
anys := make([]*anypb.Any, 0, len(e.details))
for _, detail := range e.details {
anys = append(anys, detail.pbAny)
}
return anys
}
// IsNotModifiedError checks whether the supplied error indicates that the
// requested resource hasn't changed. It only returns true if the server used
// [NewNotModifiedError] in response to a Connect-protocol RPC made with an
// HTTP GET.
func IsNotModifiedError(err error) bool {
return errors.Is(err, errNotModified)
}
// errorf calls fmt.Errorf with the supplied template and arguments, then wraps
// the resulting error.
func errorf(c Code, template string, args ...any) *Error {
return NewError(c, fmt.Errorf(template, args...))
}
// asError uses errors.As to unwrap any error and look for a connect *Error.
func asError(err error) (*Error, bool) {
var connectErr *Error
ok := errors.As(err, &connectErr)
return connectErr, ok
}
// wrapIfUncoded ensures that all errors are wrapped. It leaves already-wrapped
// errors unchanged, uses wrapIfContextError to apply codes to context.Canceled
// and context.DeadlineExceeded, and falls back to wrapping other errors with
// CodeUnknown.
func wrapIfUncoded(err error) error {
if err == nil {
return nil
}
maybeCodedErr := wrapIfContextError(err)
if _, ok := asError(maybeCodedErr); ok {
return maybeCodedErr
}
return NewError(CodeUnknown, maybeCodedErr)
}
// wrapIfContextError applies CodeCanceled or CodeDeadlineExceeded to Go's
// context.Canceled and context.DeadlineExceeded errors, but only if they
// haven't already been wrapped.
func wrapIfContextError(err error) error {
if err == nil {
return nil
}
if _, ok := asError(err); ok {
return err
}
if errors.Is(err, context.Canceled) {
return NewError(CodeCanceled, err)
}
if errors.Is(err, context.DeadlineExceeded) {
return NewError(CodeDeadlineExceeded, err)
}
// Ick, some dial errors can be returned as os.ErrDeadlineExceeded
// instead of context.DeadlineExceeded :(
// https://github.com/golang/go/issues/64449
if errors.Is(err, os.ErrDeadlineExceeded) {
return NewError(CodeDeadlineExceeded, err)
}
return err
}
// wrapIfContextDone wraps errors with CodeCanceled or CodeDeadlineExceeded
// if the context is done. It leaves already-wrapped errors unchanged.
func wrapIfContextDone(ctx context.Context, err error) error {
if err == nil {
return nil
}
err = wrapIfContextError(err)
if _, ok := asError(err); ok {
return err
}
ctxErr := ctx.Err()
if errors.Is(ctxErr, context.Canceled) {
return NewError(CodeCanceled, err)
} else if errors.Is(ctxErr, context.DeadlineExceeded) {
return NewError(CodeDeadlineExceeded, err)
}
return err
}
// wrapIfLikelyH2CNotConfiguredError adds a wrapping error that has a message
// telling the caller that they likely need to use h2c but are using a raw http.Client{}.
//
// This happens when running a gRPC-only server.
// This is fragile and may break over time, and this should be considered a best-effort.
func wrapIfLikelyH2CNotConfiguredError(request *http.Request, err error) error {
if err == nil {
return nil
}
if _, ok := asError(err); ok {
return err
}
if url := request.URL; url != nil && url.Scheme != "http" {
// If the scheme is not http, we definitely do not have an h2c error, so just return.
return err
}
// net/http code has been investigated and there is no typing of any of these errors
// they are all created with fmt.Errorf
// grpc-go returns the first error 2/3-3/4 of the time, and the second error 1/4-1/3 of the time
if errString := err.Error(); strings.HasPrefix(errString, `Post "`) &&
(strings.Contains(errString, `net/http: HTTP/1.x transport connection broken: malformed HTTP response`) ||
strings.HasSuffix(errString, `write: broken pipe`)) {
return fmt.Errorf("possible h2c configuration issue when talking to gRPC server, see %s: %w", commonErrorsURL, err)
}
return err
}
// wrapIfLikelyWithGRPCNotUsedError adds a wrapping error that has a message
// telling the caller that they likely forgot to use connect.WithGRPC().
//
// This happens when running a gRPC-only server.
// This is fragile and may break over time, and this should be considered a best-effort.
func wrapIfLikelyWithGRPCNotUsedError(err error) error {
if err == nil {
return nil
}
if _, ok := asError(err); ok {
return err
}
// golang.org/x/net code has been investigated and there is no typing of this error
// it is created with fmt.Errorf
// http2/transport.go:573: return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err)
if errString := err.Error(); strings.HasPrefix(errString, `Post "`) &&
strings.Contains(errString, `http2: Transport: cannot retry err`) &&
strings.HasSuffix(errString, `after Request.Body was written; define Request.GetBody to avoid this error`) {
return fmt.Errorf("possible missing connect.WithGPRC() client option when talking to gRPC server, see %s: %w", commonErrorsURL, err)
}
return err
}
// HTTP/2 has its own set of error codes, which it sends in RST_STREAM frames.
// When the server sends one of these errors, we should map it back into our
// RPC error codes following
// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#http2-transport-mapping.
//
// This would be vastly simpler if we were using x/net/http2 directly, since
// the StreamError type is exported. When x/net/http2 gets vendored into
// net/http, though, all these types become unexported...so we're left with
// string munging.
func wrapIfRSTError(err error) error {
const (
streamErrPrefix = "stream error: "
fromPeerSuffix = "; received from peer"
)
if err == nil {
return nil
}
if _, ok := asError(err); ok {
return err
}
if urlErr := new(url.Error); errors.As(err, &urlErr) {
// If we get an RST_STREAM error from http.Client.Do, it's wrapped in a
// *url.Error.
err = urlErr.Unwrap()
}
msg := err.Error()
if !strings.HasPrefix(msg, streamErrPrefix) {
return err
}
if !strings.HasSuffix(msg, fromPeerSuffix) {
return err
}
msg = strings.TrimSuffix(msg, fromPeerSuffix)
i := strings.LastIndex(msg, ";")
if i < 0 || i >= len(msg)-1 {
return err
}
msg = msg[i+1:]
msg = strings.TrimSpace(msg)
switch msg {
case "NO_ERROR", "PROTOCOL_ERROR", "INTERNAL_ERROR", "FLOW_CONTROL_ERROR",
"SETTINGS_TIMEOUT", "FRAME_SIZE_ERROR", "COMPRESSION_ERROR", "CONNECT_ERROR":
return NewError(CodeInternal, err)
case "REFUSED_STREAM":
return NewError(CodeUnavailable, err)
case "CANCEL":
return NewError(CodeCanceled, err)
case "ENHANCE_YOUR_CALM":
return NewError(CodeResourceExhausted, fmt.Errorf("bandwidth exhausted: %w", err))
case "INADEQUATE_SECURITY":
return NewError(CodePermissionDenied, fmt.Errorf("transport protocol insecure: %w", err))
default:
return err
}
}
// wrapIfMaxBytesError wraps errors returned reading from a http.MaxBytesHandler
// whose limit has been exceeded.
func wrapIfMaxBytesError(err error, tmpl string, args ...any) error {
if err == nil {
return nil
}
if _, ok := asError(err); ok {
return err
}
var maxBytesErr *http.MaxBytesError
if ok := errors.As(err, &maxBytesErr); !ok {
return err
}
prefix := fmt.Sprintf(tmpl, args...)
return errorf(CodeResourceExhausted, "%s: exceeded %d byte http.MaxBytesReader limit", prefix, maxBytesErr.Limit)
}
func typeNameForURL(url string) string {
return url[strings.LastIndexByte(url, '/')+1:]
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// protocolType is one of the supported RPC protocols.
type protocolType uint8
const (
unknownProtocol protocolType = iota
connectUnaryProtocol
connectStreamProtocol
grpcProtocol
grpcWebProtocol
)
// An ErrorWriter writes errors to an [http.ResponseWriter] in the format
// expected by an RPC client. This is especially useful in server-side net/http
// middleware, where you may wish to handle requests from RPC and non-RPC
// clients with the same code.
//
// ErrorWriters are safe to use concurrently.
type ErrorWriter struct {
bufferPool *bufferPool
protobuf Codec
requireConnectProtocolHeader bool
}
// NewErrorWriter constructs an ErrorWriter. Handler options may be passed to
// configure the error writer behaviour to match the handlers.
// [WithRequireConnectProtocolHeader] will assert that Connect protocol
// requests include the version header allowing the error writer to correctly
// classify the request.
// Options supplied via [WithConditionalHandlerOptions] are ignored.
func NewErrorWriter(opts ...HandlerOption) *ErrorWriter {
config := newHandlerConfig("", StreamTypeUnary, opts)
codecs := newReadOnlyCodecs(config.Codecs)
return &ErrorWriter{
bufferPool: config.BufferPool,
protobuf: codecs.Protobuf(),
requireConnectProtocolHeader: config.RequireConnectProtocolHeader,
}
}
func (w *ErrorWriter) classifyRequest(request *http.Request) protocolType {
ctype := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType))
isPost := request.Method == http.MethodPost
isGet := request.Method == http.MethodGet
switch {
case isPost && (ctype == grpcContentTypeDefault || strings.HasPrefix(ctype, grpcContentTypePrefix)):
return grpcProtocol
case isPost && (ctype == grpcWebContentTypeDefault || strings.HasPrefix(ctype, grpcWebContentTypePrefix)):
return grpcWebProtocol
case isPost && strings.HasPrefix(ctype, connectStreamingContentTypePrefix):
// Streaming ignores the requireConnectProtocolHeader option as the
// Content-Type is enough to determine the protocol.
if err := connectCheckProtocolVersion(request, false /* required */); err != nil {
return unknownProtocol
}
return connectStreamProtocol
case isPost && strings.HasPrefix(ctype, connectUnaryContentTypePrefix):
if err := connectCheckProtocolVersion(request, w.requireConnectProtocolHeader); err != nil {
return unknownProtocol
}
return connectUnaryProtocol
case isGet:
if err := connectCheckProtocolVersion(request, w.requireConnectProtocolHeader); err != nil {
return unknownProtocol
}
return connectUnaryProtocol
default:
return unknownProtocol
}
}
// IsSupported checks whether a request is using one of the ErrorWriter's
// supported RPC protocols.
func (w *ErrorWriter) IsSupported(request *http.Request) bool {
return w.classifyRequest(request) != unknownProtocol
}
// Write an error, using the format appropriate for the RPC protocol in use.
// Callers should first use IsSupported to verify that the request is using one
// of the ErrorWriter's supported RPC protocols. If the protocol is unknown,
// Write will send the error as unprefixed, Connect-formatted JSON.
//
// Write does not read or close the request body.
func (w *ErrorWriter) Write(response http.ResponseWriter, request *http.Request, err error) error {
ctype := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType))
switch protocolType := w.classifyRequest(request); protocolType {
case connectStreamProtocol:
setHeaderCanonical(response.Header(), headerContentType, ctype)
return w.writeConnectStreaming(response, err)
case grpcProtocol:
setHeaderCanonical(response.Header(), headerContentType, ctype)
return w.writeGRPC(response, err)
case grpcWebProtocol:
setHeaderCanonical(response.Header(), headerContentType, ctype)
return w.writeGRPCWeb(response, err)
case unknownProtocol, connectUnaryProtocol:
fallthrough
default:
// Unary errors are always JSON. Unknown protocols are treated as unary
// because they are likely to be Connect clients and will still be able to
// parse the error as it's in a human-readable format.
setHeaderCanonical(response.Header(), headerContentType, connectUnaryContentTypeJSON)
return w.writeConnectUnary(response, err)
}
}
func (w *ErrorWriter) writeConnectUnary(response http.ResponseWriter, err error) error {
if connectErr, ok := asError(err); ok && !connectErr.wireErr {
mergeNonProtocolHeaders(response.Header(), connectErr.meta)
}
response.WriteHeader(connectCodeToHTTP(CodeOf(err)))
data, marshalErr := json.Marshal(newConnectWireError(err))
if marshalErr != nil {
return fmt.Errorf("marshal error: %w", marshalErr)
}
_, writeErr := response.Write(data)
return writeErr
}
func (w *ErrorWriter) writeConnectStreaming(response http.ResponseWriter, err error) error {
response.WriteHeader(http.StatusOK)
marshaler := &connectStreamingMarshaler{
envelopeWriter: envelopeWriter{
sender: writeSender{writer: response},
bufferPool: w.bufferPool,
},
}
// MarshalEndStream returns *Error: check return value to avoid typed nils.
if marshalErr := marshaler.MarshalEndStream(err, make(http.Header)); marshalErr != nil {
return marshalErr
}
return nil
}
func (w *ErrorWriter) writeGRPC(response http.ResponseWriter, err error) error {
trailers := make(http.Header, 2) // need space for at least code & message
grpcErrorToTrailer(trailers, w.protobuf, err)
// To make net/http reliably send trailers without a body, we must set the
// Trailers header rather than using http.TrailerPrefix. See
// https://github.com/golang/go/issues/54723.
keys := make([]string, 0, len(trailers))
for k := range trailers {
keys = append(keys, k)
}
setHeaderCanonical(response.Header(), headerTrailer, strings.Join(keys, ","))
response.WriteHeader(http.StatusOK)
mergeHeaders(response.Header(), trailers)
return nil
}
func (w *ErrorWriter) writeGRPCWeb(response http.ResponseWriter, err error) error {
// This is a trailers-only response. To match the behavior of Envoy and
// protocol_grpc.go, put the trailers in the HTTP headers.
grpcErrorToTrailer(response.Header(), w.protobuf, err)
response.WriteHeader(http.StatusOK)
return nil
}
+423
View File
@@ -0,0 +1,423 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"net/http"
)
// A Handler is the server-side implementation of a single RPC defined by a
// service schema.
//
// By default, Handlers support the Connect, gRPC, and gRPC-Web protocols with
// the binary Protobuf and JSON codecs. They support gzip compression using the
// standard library's [compress/gzip].
type Handler struct {
spec Spec
implementation StreamingHandlerFunc
protocolHandlers map[string][]protocolHandler // Method to protocol handlers
allowMethod string // Allow header
acceptPost string // Accept-Post header
}
// NewUnaryHandler constructs a [Handler] for a request-response procedure.
func NewUnaryHandler[Req, Res any](
procedure string,
unary func(context.Context, *Request[Req]) (*Response[Res], error),
options ...HandlerOption,
) *Handler {
// Wrap the strongly-typed implementation so we can apply interceptors.
untyped := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
typed, ok := request.(*Request[Req])
if !ok {
return nil, errorf(CodeInternal, "unexpected handler request type %T", request)
}
res, err := unary(ctx, typed)
if res == nil && err == nil {
// This is going to panic during serialization. Debugging is much easier
// if we panic here instead, so we can include the procedure name.
panic(procedure + " returned nil *connect.Response and nil error") //nolint: forbidigo
}
return res, err
})
config := newHandlerConfig(procedure, StreamTypeUnary, options)
if interceptor := config.Interceptor; interceptor != nil {
untyped = interceptor.WrapUnary(untyped)
}
// Given a stream, how should we call the unary function?
implementation := func(ctx context.Context, conn StreamingHandlerConn) error {
request, err := receiveUnaryRequest[Req](conn, config.Initializer)
if err != nil {
return err
}
// Add the request header to the context, and store the response header
// and trailer to propagate back to the caller.
info := &handlerCallInfo{
peer: request.Peer(),
spec: request.Spec(),
method: request.HTTPMethod(),
requestHeader: request.Header(),
}
ctx = newHandlerContext(ctx, info)
response, err := untyped(ctx, request)
// Add response headers/trailers from the context callinfo into the conn if they exist
if info.responseHeader != nil {
mergeNonProtocolHeaders(conn.ResponseHeader(), info.responseHeader)
}
if info.responseTrailer != nil {
mergeNonProtocolHeaders(conn.ResponseTrailer(), info.responseTrailer)
}
if err != nil {
return err
}
// Add response headers/trailers from the response into the conn if they exist
if len(response.Header()) != 0 {
mergeNonProtocolHeaders(conn.ResponseHeader(), response.Header())
}
if len(response.Trailer()) != 0 {
mergeNonProtocolHeaders(conn.ResponseTrailer(), response.Trailer())
}
return conn.Send(response.Any())
}
protocolHandlers := config.newProtocolHandlers()
return &Handler{
spec: config.newSpec(),
implementation: implementation,
protocolHandlers: mappedMethodHandlers(protocolHandlers),
allowMethod: sortedAllowMethodValue(protocolHandlers),
acceptPost: sortedAcceptPostValue(protocolHandlers),
}
}
// NewUnaryHandlerSimple constructs a [Handler] for a request-response procedure using the
// function signature associated with the "simple" generation option.
//
// This option eliminates the [Request] and [Response] wrappers, and instead uses the
// context.Context to propagate information such as headers.
func NewUnaryHandlerSimple[Req, Res any](
procedure string,
unary func(context.Context, *Req) (*Res, error),
options ...HandlerOption,
) *Handler {
return NewUnaryHandler(
procedure,
func(ctx context.Context, request *Request[Req]) (*Response[Res], error) {
responseMsg, err := unary(ctx, request.Msg)
if err != nil {
return nil, err
}
return NewResponse(responseMsg), nil
},
options...,
)
}
// NewClientStreamHandler constructs a [Handler] for a client streaming procedure.
func NewClientStreamHandler[Req, Res any](
procedure string,
implementation func(context.Context, *ClientStream[Req]) (*Response[Res], error),
options ...HandlerOption,
) *Handler {
config := newHandlerConfig(procedure, StreamTypeClient, options)
return newStreamHandler(
config,
func(ctx context.Context, conn StreamingHandlerConn) error {
stream := &ClientStream[Req]{
conn: conn,
initializer: config.Initializer,
}
ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{
conn: conn,
})
res, err := implementation(ctx, stream)
if err != nil {
return err
}
if res == nil {
// This is going to panic during serialization. Debugging is much easier
// if we panic here instead, so we can include the procedure name.
panic(procedure + " returned nil *connect.Response and nil error") //nolint: forbidigo
}
mergeHeaders(conn.ResponseHeader(), res.header)
mergeHeaders(conn.ResponseTrailer(), res.trailer)
return conn.Send(res.Msg)
},
)
}
// NewClientStreamHandlerSimple constructs a [Handler] for a request-streaming procedure
// using the function signature associated with the "simple" generation option.
//
// This option eliminates the [Response] wrapper, and instead uses the context.Context
// to propagate information such as headers.
func NewClientStreamHandlerSimple[Req, Res any](
procedure string,
implementation func(context.Context, *ClientStream[Req]) (*Res, error),
options ...HandlerOption,
) *Handler {
return NewClientStreamHandler(
procedure,
func(ctx context.Context, stream *ClientStream[Req]) (*Response[Res], error) {
responseMsg, err := implementation(ctx, stream)
if err != nil {
return nil, err
}
return NewResponse(responseMsg), nil
},
options...,
)
}
// NewServerStreamHandler constructs a [Handler] for a server streaming procedure.
func NewServerStreamHandler[Req, Res any](
procedure string,
implementation func(context.Context, *Request[Req], *ServerStream[Res]) error,
options ...HandlerOption,
) *Handler {
config := newHandlerConfig(procedure, StreamTypeServer, options)
return newStreamHandler(
config,
func(ctx context.Context, conn StreamingHandlerConn) error {
req, err := receiveUnaryRequest[Req](conn, config.Initializer)
if err != nil {
return err
}
ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{
conn: conn,
})
return implementation(ctx, req, &ServerStream[Res]{conn: conn})
},
)
}
// NewServerStreamHandlerSimple constructs a [Handler] a server streaming procedure using the function
// signature associated with the "simple" generation option.
//
// This option eliminates the [Request] wrapper, and instead uses the context.Context to
// propagate information such as headers.
func NewServerStreamHandlerSimple[Req, Res any](
procedure string,
implementation func(context.Context, *Req, *ServerStream[Res]) error,
options ...HandlerOption,
) *Handler {
return NewServerStreamHandler(
procedure,
func(ctx context.Context, request *Request[Req], serverStream *ServerStream[Res]) error {
return implementation(ctx, request.Msg, serverStream)
},
options...,
)
}
// NewBidiStreamHandler constructs a [Handler] for a bidirectional streaming procedure.
func NewBidiStreamHandler[Req, Res any](
procedure string,
implementation func(context.Context, *BidiStream[Req, Res]) error,
options ...HandlerOption,
) *Handler {
config := newHandlerConfig(procedure, StreamTypeBidi, options)
return newStreamHandler(
config,
func(ctx context.Context, conn StreamingHandlerConn) error {
ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{
conn: conn,
})
return implementation(
ctx,
&BidiStream[Req, Res]{
conn: conn,
initializer: config.Initializer,
},
)
},
)
}
// ServeHTTP implements [http.Handler].
func (h *Handler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) {
// We don't need to defer functions to close the request body or read to
// EOF: the stream we construct later on already does that, and we only
// return early when dealing with misbehaving clients. In those cases, it's
// okay if we can't re-use the connection.
isBidi := (h.spec.StreamType & StreamTypeBidi) == StreamTypeBidi
if isBidi && request.ProtoMajor < 2 {
// Clients coded to expect full-duplex connections may hang if they've
// mistakenly negotiated HTTP/1.1. To unblock them, we must close the
// underlying TCP connection.
responseWriter.Header().Set("Connection", "close")
responseWriter.WriteHeader(http.StatusHTTPVersionNotSupported)
return
}
protocolHandlers := h.protocolHandlers[request.Method]
if len(protocolHandlers) == 0 {
responseWriter.Header().Set("Allow", h.allowMethod)
responseWriter.WriteHeader(http.StatusMethodNotAllowed)
return
}
contentType := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType))
// Find our implementation of the RPC protocol in use.
var protocolHandler protocolHandler
for _, handler := range protocolHandlers {
if handler.CanHandlePayload(request, contentType) {
protocolHandler = handler
break
}
}
if protocolHandler == nil {
responseWriter.Header().Set("Accept-Post", h.acceptPost)
responseWriter.WriteHeader(http.StatusUnsupportedMediaType)
return
}
if request.Method == http.MethodGet {
// A body must not be present.
hasBody := request.ContentLength > 0
if request.ContentLength < 0 {
// No content-length header.
// Test if body is empty by trying to read a single byte.
var b [1]byte
n, _ := request.Body.Read(b[:])
hasBody = n > 0
}
if hasBody {
responseWriter.WriteHeader(http.StatusUnsupportedMediaType)
return
}
_ = request.Body.Close()
}
// Establish a stream and serve the RPC.
setHeaderCanonical(request.Header, headerContentType, contentType)
setHeaderCanonical(request.Header, headerHost, request.Host)
ctx, cancel, timeoutErr := protocolHandler.SetTimeout(request) //nolint: contextcheck
if timeoutErr != nil {
ctx = request.Context()
}
if cancel != nil {
defer cancel()
}
connCloser, ok := protocolHandler.NewConn(
responseWriter,
request.WithContext(ctx),
)
if !ok {
// Failed to create stream, usually because client used an unknown
// compression algorithm. Nothing further to do.
return
}
if timeoutErr != nil {
_ = connCloser.Close(timeoutErr)
return
}
_ = connCloser.Close(h.implementation(ctx, connCloser))
}
type handlerConfig struct {
CompressionPools map[string]*compressionPool
CompressionNames []string
Codecs map[string]Codec
CompressMinBytes int
Interceptor Interceptor
Procedure string
Schema any
Initializer maybeInitializer
RequireConnectProtocolHeader bool
IdempotencyLevel IdempotencyLevel
BufferPool *bufferPool
ReadMaxBytes int
SendMaxBytes int
StreamType StreamType
}
func newHandlerConfig(procedure string, streamType StreamType, options []HandlerOption) *handlerConfig {
protoPath := extractProtoPath(procedure)
config := handlerConfig{
Procedure: protoPath,
CompressionPools: make(map[string]*compressionPool),
Codecs: make(map[string]Codec),
BufferPool: newBufferPool(),
StreamType: streamType,
}
withProtoBinaryCodec().applyToHandler(&config)
withProtoJSONCodecs().applyToHandler(&config)
withGzip().applyToHandler(&config)
for _, opt := range options {
opt.applyToHandler(&config)
}
return &config
}
func (c *handlerConfig) newSpec() Spec {
return Spec{
Procedure: c.Procedure,
Schema: c.Schema,
StreamType: c.StreamType,
IdempotencyLevel: c.IdempotencyLevel,
}
}
func (c *handlerConfig) newProtocolHandlers() []protocolHandler {
protocols := []protocol{
&protocolConnect{},
&protocolGRPC{web: false},
&protocolGRPC{web: true},
}
handlers := make([]protocolHandler, 0, len(protocols))
codecs := newReadOnlyCodecs(c.Codecs)
compressors := newReadOnlyCompressionPools(
c.CompressionPools,
c.CompressionNames,
)
for _, protocol := range protocols {
handlers = append(handlers, protocol.NewHandler(&protocolHandlerParams{
Spec: c.newSpec(),
Codecs: codecs,
CompressionPools: compressors,
CompressMinBytes: c.CompressMinBytes,
BufferPool: c.BufferPool,
ReadMaxBytes: c.ReadMaxBytes,
SendMaxBytes: c.SendMaxBytes,
RequireConnectProtocolHeader: c.RequireConnectProtocolHeader,
IdempotencyLevel: c.IdempotencyLevel,
}))
}
return handlers
}
func newStreamHandler(
config *handlerConfig,
implementation StreamingHandlerFunc,
) *Handler {
if ic := config.Interceptor; ic != nil {
implementation = ic.WrapStreamingHandler(implementation)
}
protocolHandlers := config.newProtocolHandlers()
return &Handler{
spec: config.newSpec(),
implementation: implementation,
protocolHandlers: mappedMethodHandlers(protocolHandlers),
allowMethod: sortedAllowMethodValue(protocolHandlers),
acceptPost: sortedAcceptPostValue(protocolHandlers),
}
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"errors"
"io"
"net/http"
)
// ClientStream is the handler's view of a client streaming RPC.
//
// It's constructed as part of [Handler] invocation, but doesn't currently have
// an exported constructor.
type ClientStream[Req any] struct {
conn StreamingHandlerConn
initializer maybeInitializer
msg *Req
err error
}
// Spec returns the specification for the RPC.
func (c *ClientStream[_]) Spec() Spec {
return c.conn.Spec()
}
// Peer describes the client for this RPC.
func (c *ClientStream[_]) Peer() Peer {
return c.conn.Peer()
}
// RequestHeader returns the headers received from the client.
func (c *ClientStream[Req]) RequestHeader() http.Header {
return c.conn.RequestHeader()
}
// Receive advances the stream to the next message, which will then be
// available through the Msg method. It returns false when the stream stops,
// either by reaching the end or by encountering an unexpected error. After
// Receive returns false, the Err method will return any unexpected error
// encountered.
func (c *ClientStream[Req]) Receive() bool {
if c.err != nil {
return false
}
c.msg = new(Req)
if err := c.initializer.maybe(c.Spec(), c.msg); err != nil {
c.err = err
return false
}
c.err = c.conn.Receive(c.msg)
return c.err == nil
}
// Msg returns the most recent message unmarshaled by a call to Receive.
func (c *ClientStream[Req]) Msg() *Req {
if c.msg == nil {
c.msg = new(Req)
}
return c.msg
}
// Err returns the first non-EOF error that was encountered by Receive.
func (c *ClientStream[Req]) Err() error {
if c.err == nil || errors.Is(c.err, io.EOF) {
return nil
}
return c.err
}
// Conn exposes the underlying StreamingHandlerConn. This may be useful if
// you'd prefer to wrap the connection in a different high-level API.
func (c *ClientStream[Req]) Conn() StreamingHandlerConn {
return c.conn
}
// ServerStream is the handler's view of a server streaming RPC.
//
// It's constructed as part of [Handler] invocation, but doesn't currently have
// an exported constructor.
type ServerStream[Res any] struct {
conn StreamingHandlerConn
}
// ResponseHeader returns the response headers. Headers are sent with the first
// call to Send.
//
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols. Applications shouldn't write them.
func (s *ServerStream[Res]) ResponseHeader() http.Header {
return s.conn.ResponseHeader()
}
// ResponseTrailer returns the response trailers. Handlers may write to the
// response trailers at any time before returning.
//
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols. Applications shouldn't write them.
func (s *ServerStream[Res]) ResponseTrailer() http.Header {
return s.conn.ResponseTrailer()
}
// Send a message to the client. The first call to Send also sends the response
// headers.
func (s *ServerStream[Res]) Send(msg *Res) error {
if msg == nil {
return s.conn.Send(nil)
}
return s.conn.Send(msg)
}
// Conn exposes the underlying StreamingHandlerConn. This may be useful if
// you'd prefer to wrap the connection in a different high-level API.
func (s *ServerStream[Res]) Conn() StreamingHandlerConn {
return s.conn
}
// BidiStream is the handler's view of a bidirectional streaming RPC.
//
// It's constructed as part of [Handler] invocation, but doesn't currently have
// an exported constructor.
type BidiStream[Req, Res any] struct {
conn StreamingHandlerConn
initializer maybeInitializer
}
// Spec returns the specification for the RPC.
func (b *BidiStream[_, _]) Spec() Spec {
return b.conn.Spec()
}
// Peer describes the client for this RPC.
func (b *BidiStream[_, _]) Peer() Peer {
return b.conn.Peer()
}
// RequestHeader returns the headers received from the client.
func (b *BidiStream[Req, Res]) RequestHeader() http.Header {
return b.conn.RequestHeader()
}
// Receive a message. When the client is done sending messages, Receive will
// return an error that wraps [io.EOF].
func (b *BidiStream[Req, Res]) Receive() (*Req, error) {
var req Req
if err := b.initializer.maybe(b.Spec(), &req); err != nil {
return nil, err
}
if err := b.conn.Receive(&req); err != nil {
return nil, err
}
return &req, nil
}
// ResponseHeader returns the response headers. Headers are sent with the first
// call to Send.
//
// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols. Applications shouldn't write them.
func (b *BidiStream[Req, Res]) ResponseHeader() http.Header {
return b.conn.ResponseHeader()
}
// ResponseTrailer returns the response trailers. Handlers may write to the
// response trailers at any time before returning.
//
// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the
// Connect and gRPC protocols. Applications shouldn't write them.
func (b *BidiStream[Req, Res]) ResponseTrailer() http.Header {
return b.conn.ResponseTrailer()
}
// Send a message to the client. The first call to Send also sends the response
// headers.
func (b *BidiStream[Req, Res]) Send(msg *Res) error {
if msg == nil {
return b.conn.Send(nil)
}
return b.conn.Send(msg)
}
// Conn exposes the underlying StreamingHandlerConn. This may be useful if
// you'd prefer to wrap the connection in a different high-level API.
func (b *BidiStream[Req, Res]) Conn() StreamingHandlerConn {
return b.conn
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"encoding/base64"
"net/http"
)
//nolint:gochecknoglobals
var protocolHeaders = map[string]struct{}{
// HTTP headers.
headerContentType: {},
headerContentLength: {},
headerContentEncoding: {},
headerHost: {},
headerUserAgent: {},
headerTrailer: {},
headerDate: {},
// Connect headers.
connectUnaryHeaderAcceptCompression: {},
connectUnaryTrailerPrefix: {},
connectStreamingHeaderCompression: {},
connectStreamingHeaderAcceptCompression: {},
connectHeaderTimeout: {},
connectHeaderProtocolVersion: {},
// gRPC headers.
grpcHeaderCompression: {},
grpcHeaderAcceptCompression: {},
grpcHeaderTimeout: {},
grpcHeaderStatus: {},
grpcHeaderMessage: {},
grpcHeaderDetails: {},
}
// EncodeBinaryHeader base64-encodes the data. It always emits unpadded values.
//
// In the Connect, gRPC, and gRPC-Web protocols, binary headers must have keys
// ending in "-Bin".
func EncodeBinaryHeader(data []byte) string {
// gRPC specification says that implementations should emit unpadded values.
return base64.RawStdEncoding.EncodeToString(data)
}
// DecodeBinaryHeader base64-decodes the data. It can decode padded or unpadded
// values. Following usual HTTP semantics, multiple base64-encoded values may
// be joined with a comma. When receiving such comma-separated values, split
// them with [strings.Split] before calling DecodeBinaryHeader.
//
// Binary headers sent using the Connect, gRPC, and gRPC-Web protocols have
// keys ending in "-Bin".
func DecodeBinaryHeader(data string) ([]byte, error) {
if len(data)%4 != 0 {
// Data definitely isn't padded.
return base64.RawStdEncoding.DecodeString(data)
}
// Either the data was padded, or padding wasn't necessary. In both cases,
// the padding-aware decoder works.
return base64.StdEncoding.DecodeString(data)
}
func mergeHeaders(into, from http.Header) {
for key, vals := range from {
if len(vals) == 0 {
// For response trailers, net/http will pre-populate entries
// with nil values based on the "Trailer" header. But if there
// are no actual values for those keys, we skip them.
continue
}
into[key] = append(into[key], vals...)
}
}
// mergeNonProtocolHeaders merges headers excluding protocol headers defined in
// protocolHeaders.
func mergeNonProtocolHeaders(into, from http.Header) {
for key, vals := range from {
if len(vals) == 0 {
// For response trailers, net/http will pre-populate entries
// with nil values based on the "Trailer" header. But if there
// are no actual values for those keys, we skip them.
continue
}
if _, isProtocolHeader := protocolHeaders[key]; !isProtocolHeader {
into[key] = append(into[key], vals...)
}
}
}
// getHeaderCanonical is a shortcut for Header.Get() which
// bypasses the CanonicalMIMEHeaderKey operation when we
// know the key is already in canonical form.
func getHeaderCanonical(h http.Header, key string) string {
if h == nil {
return ""
}
v := h[key]
if len(v) == 0 {
return ""
}
return v[0]
}
// setHeaderCanonical is a shortcut for Header.Set() which
// bypasses the CanonicalMIMEHeaderKey operation when we
// know the key is already in canonical form.
func setHeaderCanonical(h http.Header, key, value string) {
h[key] = []string{value}
}
// delHeaderCanonical is a shortcut for Header.Del() which
// bypasses the CanonicalMIMEHeaderKey operation when we
// know the key is already in canonical form.
func delHeaderCanonical(h http.Header, key string) {
delete(h, key)
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import "fmt"
// An IdempotencyLevel is a value that declares how "idempotent" an RPC is. This
// value can affect RPC behaviors, such as determining whether it is safe to
// retry a request, or what kinds of request modalities are allowed for a given
// procedure.
type IdempotencyLevel int
// NOTE: For simplicity, these should be kept in sync with the values of the
// google.protobuf.MethodOptions.IdempotencyLevel enumeration.
const (
// IdempotencyUnknown is the default idempotency level. A procedure with
// this idempotency level may not be idempotent. This is appropriate for
// any kind of procedure.
IdempotencyUnknown IdempotencyLevel = 0
// IdempotencyNoSideEffects is the idempotency level that specifies that a
// given call has no side-effects. This is equivalent to [RFC 9110 § 9.2.1]
// "safe" methods in terms of semantics. This procedure should not mutate
// any state. This idempotency level is appropriate for queries, or anything
// that would be suitable for an HTTP GET request. In addition, due to the
// lack of side-effects, such a procedure would be suitable to retry and
// expect that the results will not be altered by preceding attempts.
//
// [RFC 9110 § 9.2.1]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.1
IdempotencyNoSideEffects IdempotencyLevel = 1
// IdempotencyIdempotent is the idempotency level that specifies that a
// given call is "idempotent", such that multiple instances of the same
// request to this procedure would have the same side-effects as a single
// request. This is equivalent to [RFC 9110 § 9.2.2] "idempotent" methods.
// This level is a subset of the previous level. This idempotency level is
// appropriate for any procedure that is safe to retry multiple times
// and be guaranteed that the response and side-effects will not be altered
// as a result of multiple attempts, for example, entity deletion requests.
//
// [RFC 9110 § 9.2.2]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2
IdempotencyIdempotent IdempotencyLevel = 2
)
func (i IdempotencyLevel) String() string {
switch i {
case IdempotencyUnknown:
return "idempotency_unknown"
case IdempotencyNoSideEffects:
return "no_side_effects"
case IdempotencyIdempotent:
return "idempotent"
}
return fmt.Sprintf("idempotency_%d", i)
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"errors"
)
var (
// errNewClientContextProhibited signals that a new client context was created
// in an interceptor, which is prohibited.
errNewClientContextProhibited = errors.New("creating a new context in an interceptor is prohibited")
)
// UnaryFunc is the generic signature of a unary RPC. Interceptors may wrap
// Funcs.
//
// The type of the request and response structs depend on the codec being used.
// When using Protobuf, request.Any() and response.Any() will always be
// [proto.Message] implementations.
type UnaryFunc func(context.Context, AnyRequest) (AnyResponse, error)
// StreamingClientFunc is the generic signature of a streaming RPC from the client's
// perspective. Interceptors may wrap StreamingClientFuncs.
type StreamingClientFunc func(context.Context, Spec) StreamingClientConn
// StreamingHandlerFunc is the generic signature of a streaming RPC from the
// handler's perspective. Interceptors may wrap StreamingHandlerFuncs.
type StreamingHandlerFunc func(context.Context, StreamingHandlerConn) error
// An Interceptor adds logic to a generated handler or client, like the
// decorators or middleware you may have seen in other libraries. Interceptors
// may mutate requests and responses, handle errors, retry, recover from panics,
// emit logs and metrics, or do nearly anything else.
//
// The returned functions must be safe to call concurrently.
type Interceptor interface {
WrapUnary(UnaryFunc) UnaryFunc
WrapStreamingClient(StreamingClientFunc) StreamingClientFunc
WrapStreamingHandler(StreamingHandlerFunc) StreamingHandlerFunc
}
// UnaryInterceptorFunc is a simple Interceptor implementation that only
// wraps unary RPCs. It has no effect on streaming RPCs.
type UnaryInterceptorFunc func(UnaryFunc) UnaryFunc
// WrapUnary implements [Interceptor] by applying the interceptor function.
func (f UnaryInterceptorFunc) WrapUnary(next UnaryFunc) UnaryFunc { return f(next) }
// WrapStreamingClient implements [Interceptor] with a no-op.
func (f UnaryInterceptorFunc) WrapStreamingClient(next StreamingClientFunc) StreamingClientFunc {
return next
}
// WrapStreamingHandler implements [Interceptor] with a no-op.
func (f UnaryInterceptorFunc) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc {
return next
}
// A chain composes multiple interceptors into one.
type chain struct {
interceptors []Interceptor
}
// newChain composes multiple interceptors into one.
func newChain(interceptors []Interceptor) *chain {
// We usually wrap in reverse order to have the first interceptor from
// the slice act first. Rather than doing this dance repeatedly, reverse the
// interceptor order now.
var chain chain
for i := len(interceptors) - 1; i >= 0; i-- {
if interceptor := interceptors[i]; interceptor != nil {
chain.interceptors = append(chain.interceptors, interceptor)
}
}
return &chain
}
func (c *chain) WrapUnary(next UnaryFunc) UnaryFunc {
for _, interceptor := range c.interceptors {
next = unaryThunk(next)
next = interceptor.WrapUnary(next)
}
return next
}
func (c *chain) WrapStreamingClient(next StreamingClientFunc) StreamingClientFunc {
for _, interceptor := range c.interceptors {
next = streamingClientThunk(next)
next = interceptor.WrapStreamingClient(next)
}
return next
}
func (c *chain) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc {
for _, interceptor := range c.interceptors {
next = interceptor.WrapStreamingHandler(next)
}
return next
}
func unaryThunk(next UnaryFunc) UnaryFunc {
return func(ctx context.Context, req AnyRequest) (AnyResponse, error) {
if err := checkSentinel(ctx); err != nil {
return nil, err
}
return next(ctx, req)
}
}
func streamingClientThunk(next StreamingClientFunc) StreamingClientFunc {
return func(ctx context.Context, spec Spec) StreamingClientConn {
if err := checkSentinel(ctx); err != nil {
return &errStreamingClientConn{err: err}
}
return next(ctx, spec)
}
}
func checkSentinel(ctx context.Context) error {
if ctx.Value(clientCallInfoContextKey{}) != ctx.Value(sentinelContextKey{}) {
return errNewClientContextProhibited
}
return nil
}
@@ -0,0 +1,165 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.9
// protoc (unknown)
// source: connectext/grpc/status/v1/status.proto
// This package is for internal use by Connect, and provides no backward
// compatibility guarantees whatsoever.
package statusv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
anypb "google.golang.org/protobuf/types/known/anypb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// See https://cloud.google.com/apis/design/errors.
//
// This struct must remain binary-compatible with
// https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto.
type Status struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` // a google.rpc.Code
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // developer-facing, English (localize in details or client-side)
Details []*anypb.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Status) Reset() {
*x = Status{}
mi := &file_connectext_grpc_status_v1_status_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Status) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Status) ProtoMessage() {}
func (x *Status) ProtoReflect() protoreflect.Message {
mi := &file_connectext_grpc_status_v1_status_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Status.ProtoReflect.Descriptor instead.
func (*Status) Descriptor() ([]byte, []int) {
return file_connectext_grpc_status_v1_status_proto_rawDescGZIP(), []int{0}
}
func (x *Status) GetCode() int32 {
if x != nil {
return x.Code
}
return 0
}
func (x *Status) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *Status) GetDetails() []*anypb.Any {
if x != nil {
return x.Details
}
return nil
}
var File_connectext_grpc_status_v1_status_proto protoreflect.FileDescriptor
const file_connectext_grpc_status_v1_status_proto_rawDesc = "" +
"\n" +
"&connectext/grpc/status/v1/status.proto\x12\x0egrpc.status.v1\x1a\x19google/protobuf/any.proto\"f\n" +
"\x06Status\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12.\n" +
"\adetails\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\adetailsB\xc3\x01\n" +
"\x12com.grpc.status.v1B\vStatusProtoP\x01ZFconnectrpc.com/connect/internal/gen/connectext/grpc/status/v1;statusv1\xa2\x02\x03GSX\xaa\x02\x0eGrpc.Status.V1\xca\x02\x0eGrpc\\Status\\V1\xe2\x02\x1aGrpc\\Status\\V1\\GPBMetadata\xea\x02\x10Grpc::Status::V1b\x06proto3"
var (
file_connectext_grpc_status_v1_status_proto_rawDescOnce sync.Once
file_connectext_grpc_status_v1_status_proto_rawDescData []byte
)
func file_connectext_grpc_status_v1_status_proto_rawDescGZIP() []byte {
file_connectext_grpc_status_v1_status_proto_rawDescOnce.Do(func() {
file_connectext_grpc_status_v1_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectext_grpc_status_v1_status_proto_rawDesc), len(file_connectext_grpc_status_v1_status_proto_rawDesc)))
})
return file_connectext_grpc_status_v1_status_proto_rawDescData
}
var file_connectext_grpc_status_v1_status_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_connectext_grpc_status_v1_status_proto_goTypes = []any{
(*Status)(nil), // 0: grpc.status.v1.Status
(*anypb.Any)(nil), // 1: google.protobuf.Any
}
var file_connectext_grpc_status_v1_status_proto_depIdxs = []int32{
1, // 0: grpc.status.v1.Status.details:type_name -> google.protobuf.Any
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_connectext_grpc_status_v1_status_proto_init() }
func file_connectext_grpc_status_v1_status_proto_init() {
if File_connectext_grpc_status_v1_status_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectext_grpc_status_v1_status_proto_rawDesc), len(file_connectext_grpc_status_v1_status_proto_rawDesc)),
NumEnums: 0,
NumMessages: 1,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_connectext_grpc_status_v1_status_proto_goTypes,
DependencyIndexes: file_connectext_grpc_status_v1_status_proto_depIdxs,
MessageInfos: file_connectext_grpc_status_v1_status_proto_msgTypes,
}.Build()
File_connectext_grpc_status_v1_status_proto = out.File
file_connectext_grpc_status_v1_status_proto_goTypes = nil
file_connectext_grpc_status_v1_status_proto_depIdxs = nil
}
+647
View File
@@ -0,0 +1,647 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"compress/gzip"
"context"
"io"
"net/http"
)
// A ClientOption configures a [Client].
//
// In addition to any options grouped in the documentation below, remember that
// any [Option] is also a valid ClientOption.
type ClientOption interface {
applyToClient(*clientConfig)
}
// WithAcceptCompression makes a compression algorithm available to a client.
// Clients ask servers to compress responses using any of the registered
// algorithms. The first registered algorithm is treated as the least
// preferred, and the last registered algorithm is the most preferred.
//
// It's safe to use this option liberally: servers will ignore any
// compression algorithms they don't support. To compress requests, pair this
// option with [WithSendCompression]. To remove support for a
// previously-registered compression algorithm, use WithAcceptCompression with
// nil decompressor and compressor constructors.
//
// Clients accept gzipped responses by default, using a compressor backed by the
// standard library's [gzip] package with the default compression level. Use
// [WithSendGzip] to compress requests with gzip.
//
// Calling WithAcceptCompression with an empty name is a no-op.
func WithAcceptCompression(
name string,
newDecompressor func() Decompressor,
newCompressor func() Compressor,
) ClientOption {
return &compressionOption{
Name: name,
CompressionPool: newCompressionPool(newDecompressor, newCompressor),
}
}
// WithClientOptions composes multiple ClientOptions into one.
func WithClientOptions(options ...ClientOption) ClientOption {
return &clientOptionsOption{options}
}
// WithGRPC configures clients to use the HTTP/2 gRPC protocol.
func WithGRPC() ClientOption {
return &grpcOption{web: false}
}
// WithGRPCWeb configures clients to use the gRPC-Web protocol.
func WithGRPCWeb() ClientOption {
return &grpcOption{web: true}
}
// WithProtoJSON configures a client to send JSON-encoded data instead of
// binary Protobuf. It uses the standard Protobuf JSON mapping as implemented
// by [google.golang.org/protobuf/encoding/protojson]: fields are named using
// lowerCamelCase, zero values are omitted, missing required fields are errors,
// enums are emitted as strings, etc.
func WithProtoJSON() ClientOption {
return WithCodec(&protoJSONCodec{codecNameJSON})
}
// WithSendCompression configures the client to use the specified algorithm to
// compress request messages. If the algorithm has not been registered using
// [WithAcceptCompression], the client will return errors at runtime.
//
// Because some servers don't support compression, clients default to sending
// uncompressed requests.
func WithSendCompression(name string) ClientOption {
return &sendCompressionOption{Name: name}
}
// WithSendGzip configures the client to gzip requests. Since clients have
// access to a gzip compressor by default, WithSendGzip doesn't require
// [WithSendCompression].
//
// Some servers don't support gzip, so clients default to sending uncompressed
// requests.
func WithSendGzip() ClientOption {
return WithSendCompression(compressionGzip)
}
// A HandlerOption configures a [Handler].
//
// In addition to any options grouped in the documentation below, remember that
// any [Option] is also a HandlerOption.
type HandlerOption interface {
applyToHandler(*handlerConfig)
}
// WithCompression configures handlers to support a compression algorithm.
// Clients may send messages compressed with that algorithm and/or request
// compressed responses. The [Compressor] and [Decompressor] produced by the
// supplied constructors must use the same algorithm. Internally, Connect pools
// compressors and decompressors.
//
// By default, handlers support gzip using the standard library's
// [compress/gzip] package at the default compression level. To remove support for
// a previously-registered compression algorithm, use WithCompression with nil
// decompressor and compressor constructors.
//
// Calling WithCompression with an empty name is a no-op.
func WithCompression(
name string,
newDecompressor func() Decompressor,
newCompressor func() Compressor,
) HandlerOption {
return &compressionOption{
Name: name,
CompressionPool: newCompressionPool(newDecompressor, newCompressor),
}
}
// WithHandlerOptions composes multiple HandlerOptions into one.
func WithHandlerOptions(options ...HandlerOption) HandlerOption {
return &handlerOptionsOption{options}
}
// WithRecover adds an interceptor that recovers from panics. The supplied
// function receives the context, [Spec], request headers, and the recovered
// value (which may be nil). It must return an error to send back to the
// client. It may also log the panic, emit metrics, or execute other
// error-handling logic. Handler functions must be safe to call concurrently.
//
// To preserve compatibility with [net/http]'s semantics, this interceptor
// doesn't handle panics with [http.ErrAbortHandler].
//
// By default, handlers don't recover from panics. Because the standard
// library's [http.Server] recovers from panics by default, this option isn't
// usually necessary to prevent crashes. Instead, it helps servers collect
// RPC-specific data during panics and send a more detailed error to
// clients.
func WithRecover(handle func(context.Context, Spec, http.Header, any) error) HandlerOption {
return WithInterceptors(&recoverHandlerInterceptor{handle: handle})
}
// WithRequireConnectProtocolHeader configures the Handler to require requests
// using the Connect RPC protocol to include the Connect-Protocol-Version
// header. This ensures that HTTP proxies and net/http middleware can easily
// identify valid Connect requests, even if they use a common Content-Type like
// application/json. However, it makes ad-hoc requests with tools like cURL
// more laborious. Streaming requests are not affected by this option.
//
// This option has no effect if the client uses the gRPC or gRPC-Web protocols.
func WithRequireConnectProtocolHeader() HandlerOption {
return &requireConnectProtocolHeaderOption{}
}
// WithConditionalHandlerOptions allows procedures in the same service to have
// different configurations: for example, one procedure may need a much larger
// WithReadMaxBytes setting than the others.
//
// WithConditionalHandlerOptions takes a function which may inspect each
// procedure's Spec before deciding which options to apply. Returning a nil
// slice is safe.
func WithConditionalHandlerOptions(conditional func(spec Spec) []HandlerOption) HandlerOption {
return &conditionalHandlerOptions{conditional: conditional}
}
// Option implements both [ClientOption] and [HandlerOption], so it can be
// applied both client-side and server-side.
type Option interface {
ClientOption
HandlerOption
}
// WithSchema provides a parsed representation of the schema for an RPC to a
// client or handler. The supplied schema is exposed as [Spec].Schema. This
// option is typically added by generated code.
//
// For services using protobuf schemas, the supplied schema should be a
// [google.golang.org/protobuf/reflect/protoreflect.MethodDescriptor].
func WithSchema(schema any) Option {
return &schemaOption{Schema: schema}
}
// WithRequestInitializer provides a function that initializes a new message.
// It may be used to dynamically construct request messages. It is called on
// server receives to construct the message to be unmarshaled into. The message
// will be a non nil pointer to the type created by the handler. Use the Schema
// field of the [Spec] to determine the type of the message.
func WithRequestInitializer(initializer func(spec Spec, message any) error) HandlerOption {
return &initializerOption{Initializer: initializer}
}
// WithResponseInitializer provides a function that initializes a new message.
// It may be used to dynamically construct response messages. It is called on
// client receives to construct the message to be unmarshaled into. The message
// will be a non nil pointer to the type created by the client. Use the Schema
// field of the [Spec] to determine the type of the message.
func WithResponseInitializer(initializer func(spec Spec, message any) error) ClientOption {
return &initializerOption{Initializer: initializer}
}
// WithCodec registers a serialization method with a client or handler.
// Handlers may have multiple codecs registered, and use whichever the client
// chooses. Clients may only have a single codec.
//
// By default, handlers and clients support binary Protocol Buffer data using
// [google.golang.org/protobuf/proto]. Handlers also support JSON by default,
// using the standard Protobuf JSON mapping. Users with more specialized needs
// may override the default codecs by registering a new codec under the "proto"
// or "json" names. When supplying a custom "proto" codec, keep in mind that
// some unexported, protocol-specific messages are serialized using Protobuf -
// take care to fall back to the standard Protobuf implementation if
// necessary.
//
// Registering a codec with an empty name is a no-op.
func WithCodec(codec Codec) Option {
return &codecOption{Codec: codec}
}
// WithCompressMinBytes sets a minimum size threshold for compression:
// regardless of compressor configuration, messages smaller than the configured
// minimum are sent uncompressed.
//
// The default minimum is zero. Setting a minimum compression threshold may
// improve overall performance, because the CPU cost of compressing very small
// messages usually isn't worth the small reduction in network I/O.
func WithCompressMinBytes(minBytes int) Option {
return &compressMinBytesOption{Min: minBytes}
}
// WithReadMaxBytes limits the performance impact of pathologically large
// messages sent by the other party. For handlers, WithReadMaxBytes limits the size
// of a message that the client can send. For clients, WithReadMaxBytes limits the
// size of a message that the server can respond with. Limits apply to each Protobuf
// message, not to the stream as a whole.
//
// Setting WithReadMaxBytes to zero allows any message size. Both clients and
// handlers default to allowing any request size.
//
// Handlers may also use [http.MaxBytesHandler] to limit the total size of the
// HTTP request stream (rather than the per-message size). Connect handles
// [http.MaxBytesError] specially, so clients still receive errors with the
// appropriate error code and informative messages.
func WithReadMaxBytes(maxBytes int) Option {
return &readMaxBytesOption{Max: maxBytes}
}
// WithSendMaxBytes prevents sending messages too large for the client/handler
// to handle without significant performance overhead. For handlers, WithSendMaxBytes
// limits the size of a message that the handler can respond with. For clients,
// WithSendMaxBytes limits the size of a message that the client can send. Limits
// apply to each message, not to the stream as a whole.
//
// Setting WithSendMaxBytes to zero allows any message size. Both clients and
// handlers default to allowing any message size.
func WithSendMaxBytes(maxBytes int) Option {
return &sendMaxBytesOption{Max: maxBytes}
}
// WithIdempotency declares the idempotency of the procedure. This can determine
// whether a procedure call can safely be retried, and may affect which request
// modalities are allowed for a given procedure call.
//
// In most cases, you should not need to manually set this. It is normally set
// by the code generator for your schema. For protobuf schemas, it can be set like this:
//
// rpc Ping(PingRequest) returns (PingResponse) {
// option idempotency_level = NO_SIDE_EFFECTS;
// }
func WithIdempotency(idempotencyLevel IdempotencyLevel) Option {
return &idempotencyOption{idempotencyLevel: idempotencyLevel}
}
// WithHTTPGet allows Connect-protocol clients to use HTTP GET requests for
// side-effect free unary RPC calls. Typically, the service schema indicates
// which procedures are idempotent (see [WithIdempotency] for an example
// protobuf schema). The gRPC and gRPC-Web protocols are POST-only, so this
// option has no effect when combined with [WithGRPC] or [WithGRPCWeb].
//
// Using HTTP GET requests makes it easier to take advantage of CDNs, caching
// reverse proxies, and browsers' built-in caching. Note, however, that servers
// don't automatically set any cache headers; you can set cache headers using
// interceptors or by adding headers in individual procedure implementations.
//
// By default, all requests are made as HTTP POSTs.
func WithHTTPGet() ClientOption {
return &enableGet{}
}
// WithInterceptors configures a client or handler's interceptor stack. Repeated
// WithInterceptors options are applied in order, so
//
// WithInterceptors(A) + WithInterceptors(B, C) == WithInterceptors(A, B, C)
//
// Unary interceptors compose like an onion. The first interceptor provided is
// the outermost layer of the onion: it acts first on the context and request,
// and last on the response and error.
//
// Stream interceptors also behave like an onion: the first interceptor
// provided is the outermost wrapper for the [StreamingClientConn] or
// [StreamingHandlerConn]. It's the first to see sent messages and the last to
// see received messages.
//
// Applied to client and handler, WithInterceptors(A, B, ..., Y, Z) produces:
//
// client.Send() client.Receive()
// | ^
// v |
// A --- --- A
// B --- --- B
// : ... ... :
// Y --- --- Y
// Z --- --- Z
// | ^
// v |
// = = = = = = = = = = = = = = = =
// network
// = = = = = = = = = = = = = = = =
// | ^
// v |
// A --- --- A
// B --- --- B
// : ... ... :
// Y --- --- Y
// Z --- --- Z
// | ^
// v |
// handler.Receive() handler.Send()
// | ^
// | |
// '-> handler logic >-'
//
// Note that in clients, Send handles the request message(s) and Receive
// handles the response message(s). For handlers, it's the reverse. Depending
// on your interceptor's logic, you may need to wrap one method in clients and
// the other in handlers.
func WithInterceptors(interceptors ...Interceptor) Option {
return &interceptorsOption{interceptors}
}
// WithOptions composes multiple Options into one.
func WithOptions(options ...Option) Option {
return &optionsOption{options}
}
type schemaOption struct {
Schema any
}
func (o *schemaOption) applyToClient(config *clientConfig) {
config.Schema = o.Schema
}
func (o *schemaOption) applyToHandler(config *handlerConfig) {
config.Schema = o.Schema
}
type initializerOption struct {
Initializer func(spec Spec, message any) error
}
func (o *initializerOption) applyToHandler(config *handlerConfig) {
config.Initializer = maybeInitializer{initializer: o.Initializer}
}
func (o *initializerOption) applyToClient(config *clientConfig) {
config.Initializer = maybeInitializer{initializer: o.Initializer}
}
type maybeInitializer struct {
initializer func(spec Spec, message any) error
}
func (o maybeInitializer) maybe(spec Spec, message any) error {
if o.initializer != nil {
return o.initializer(spec, message)
}
return nil
}
type clientOptionsOption struct {
options []ClientOption
}
func (o *clientOptionsOption) applyToClient(config *clientConfig) {
for _, option := range o.options {
option.applyToClient(config)
}
}
type codecOption struct {
Codec Codec
}
func (o *codecOption) applyToClient(config *clientConfig) {
if o.Codec == nil || o.Codec.Name() == "" {
return
}
config.Codec = o.Codec
}
func (o *codecOption) applyToHandler(config *handlerConfig) {
if o.Codec == nil || o.Codec.Name() == "" {
return
}
config.Codecs[o.Codec.Name()] = o.Codec
}
type compressionOption struct {
Name string
CompressionPool *compressionPool
}
func (o *compressionOption) applyToClient(config *clientConfig) {
o.apply(&config.CompressionNames, config.CompressionPools)
}
func (o *compressionOption) applyToHandler(config *handlerConfig) {
o.apply(&config.CompressionNames, config.CompressionPools)
}
func (o *compressionOption) apply(configuredNames *[]string, configuredPools map[string]*compressionPool) {
if o.Name == "" {
return
}
if o.CompressionPool == nil {
delete(configuredPools, o.Name)
var names []string
for _, name := range *configuredNames {
if name == o.Name {
continue
}
names = append(names, name)
}
*configuredNames = names
return
}
configuredPools[o.Name] = o.CompressionPool
*configuredNames = append(*configuredNames, o.Name)
}
type compressMinBytesOption struct {
Min int
}
func (o *compressMinBytesOption) applyToClient(config *clientConfig) {
config.CompressMinBytes = o.Min
}
func (o *compressMinBytesOption) applyToHandler(config *handlerConfig) {
config.CompressMinBytes = o.Min
}
type readMaxBytesOption struct {
Max int
}
func (o *readMaxBytesOption) applyToClient(config *clientConfig) {
config.ReadMaxBytes = o.Max
}
func (o *readMaxBytesOption) applyToHandler(config *handlerConfig) {
config.ReadMaxBytes = o.Max
}
type sendMaxBytesOption struct {
Max int
}
func (o *sendMaxBytesOption) applyToClient(config *clientConfig) {
config.SendMaxBytes = o.Max
}
func (o *sendMaxBytesOption) applyToHandler(config *handlerConfig) {
config.SendMaxBytes = o.Max
}
type handlerOptionsOption struct {
options []HandlerOption
}
func (o *handlerOptionsOption) applyToHandler(config *handlerConfig) {
for _, option := range o.options {
option.applyToHandler(config)
}
}
type requireConnectProtocolHeaderOption struct{}
func (o *requireConnectProtocolHeaderOption) applyToHandler(config *handlerConfig) {
config.RequireConnectProtocolHeader = true
}
type idempotencyOption struct {
idempotencyLevel IdempotencyLevel
}
func (o *idempotencyOption) applyToClient(config *clientConfig) {
config.IdempotencyLevel = o.idempotencyLevel
}
func (o *idempotencyOption) applyToHandler(config *handlerConfig) {
config.IdempotencyLevel = o.idempotencyLevel
}
type grpcOption struct {
web bool
}
func (o *grpcOption) applyToClient(config *clientConfig) {
config.Protocol = &protocolGRPC{web: o.web}
}
type enableGet struct{}
func (o *enableGet) applyToClient(config *clientConfig) {
config.EnableGet = true
}
// WithHTTPGetMaxURLSize sets the maximum allowable URL length for GET requests
// made using the Connect protocol. It has no effect on gRPC or gRPC-Web
// clients, since those protocols are POST-only.
//
// Limiting the URL size is useful as most user agents, proxies, and servers
// have limits on the allowable length of a URL. For example, Apache and Nginx
// limit the size of a request line to around 8 KiB, meaning that maximum
// length of a URL is a bit smaller than this. If you run into URL size
// limitations imposed by your network infrastructure and don't know the
// maximum allowable size, or if you'd prefer to be cautious from the start, a
// 4096 byte (4 KiB) limit works with most common proxies and CDNs.
//
// If fallback is set to true and the URL would be longer than the configured
// maximum value, the request will be sent as an HTTP POST instead. If fallback
// is set to false, the request will fail with [CodeResourceExhausted].
//
// By default, Connect-protocol clients with GET requests enabled may send a
// URL of any size.
func WithHTTPGetMaxURLSize(bytes int, fallback bool) ClientOption {
return &getURLMaxBytes{Max: bytes, Fallback: fallback}
}
type getURLMaxBytes struct {
Max int
Fallback bool
}
func (o *getURLMaxBytes) applyToClient(config *clientConfig) {
config.GetURLMaxBytes = o.Max
config.GetUseFallback = o.Fallback
}
type interceptorsOption struct {
Interceptors []Interceptor
}
func (o *interceptorsOption) applyToClient(config *clientConfig) {
config.Interceptor = o.chainWith(config.Interceptor)
}
func (o *interceptorsOption) applyToHandler(config *handlerConfig) {
config.Interceptor = o.chainWith(config.Interceptor)
}
func (o *interceptorsOption) chainWith(current Interceptor) Interceptor {
if len(o.Interceptors) == 0 {
return current
}
if current == nil && len(o.Interceptors) == 1 {
return o.Interceptors[0]
}
if current == nil && len(o.Interceptors) > 1 {
return newChain(o.Interceptors)
}
return newChain(append([]Interceptor{current}, o.Interceptors...))
}
type optionsOption struct {
options []Option
}
func (o *optionsOption) applyToClient(config *clientConfig) {
for _, option := range o.options {
option.applyToClient(config)
}
}
func (o *optionsOption) applyToHandler(config *handlerConfig) {
for _, option := range o.options {
option.applyToHandler(config)
}
}
type sendCompressionOption struct {
Name string
}
func (o *sendCompressionOption) applyToClient(config *clientConfig) {
config.RequestCompressionName = o.Name
}
func withGzip() Option {
return &compressionOption{
Name: compressionGzip,
CompressionPool: newCompressionPool(
func() Decompressor { return &gzip.Reader{} },
func() Compressor { return gzip.NewWriter(io.Discard) },
),
}
}
func withProtoBinaryCodec() Option {
return WithCodec(&protoBinaryCodec{})
}
func withProtoJSONCodecs() HandlerOption {
return WithHandlerOptions(
WithCodec(&protoJSONCodec{codecNameJSON}),
WithCodec(&protoJSONCodec{codecNameJSONCharsetUTF8}),
)
}
type conditionalHandlerOptions struct {
conditional func(spec Spec) []HandlerOption
}
func (o *conditionalHandlerOptions) applyToHandler(config *handlerConfig) {
spec := config.newSpec()
if spec.Procedure == "" {
return // ignore empty specs
}
for _, option := range o.conditional(spec) {
option.applyToHandler(config)
}
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"strings"
)
// extractProtoPath returns the trailing portion of the URL's path,
// corresponding to the Protobuf package, service, and method. It always starts
// with a slash. Within connect, we use this as (1) Spec.Procedure and (2) the
// path when mounting handlers on muxes.
func extractProtoPath(path string) string {
segments := strings.Split(path, "/")
var pkg, method string
if len(segments) > 0 {
pkg = segments[0]
}
if len(segments) > 1 {
pkg = segments[len(segments)-2]
method = segments[len(segments)-1]
}
if pkg == "" {
return "/"
}
if method == "" {
return "/" + pkg
}
return "/" + pkg + "/" + method
}
+424
View File
@@ -0,0 +1,424 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"sort"
"strings"
)
// The names of the Connect, gRPC, and gRPC-Web protocols (as exposed by
// [Peer].Protocol). Additional protocols may be added in the future.
const (
ProtocolConnect = "connect"
ProtocolGRPC = "grpc"
ProtocolGRPCWeb = "grpcweb"
)
const (
headerContentType = "Content-Type"
headerContentEncoding = "Content-Encoding"
headerContentLength = "Content-Length"
headerHost = "Host"
headerUserAgent = "User-Agent"
headerTrailer = "Trailer"
headerDate = "Date"
discardLimit = 1024 * 1024 * 4 // 4MiB
)
var errNoTimeout = errors.New("no timeout")
// A Protocol defines the HTTP semantics to use when sending and receiving
// messages. It ties together codecs, compressors, and net/http to produce
// Senders and Receivers.
//
// For example, connect supports the gRPC protocol using this abstraction. Among
// many other things, the protocol implementation is responsible for
// translating timeouts from Go contexts to HTTP and vice versa. For gRPC, it
// converts timeouts to and from strings (for example, 10*time.Second <->
// "10S"), and puts those strings into the "Grpc-Timeout" HTTP header. Other
// protocols might encode durations differently, put them into a different HTTP
// header, or ignore them entirely.
//
// We don't have any short-term plans to export this interface; it's just here
// to separate the protocol-specific portions of connect from the
// protocol-agnostic plumbing.
type protocol interface {
NewHandler(*protocolHandlerParams) protocolHandler
NewClient(*protocolClientParams) (protocolClient, error)
}
// HandlerParams are the arguments provided to a Protocol's NewHandler
// method, bundled into a struct to allow backward-compatible argument
// additions. Protocol implementations should take care to use the supplied
// Spec rather than constructing their own, since new fields may have been
// added.
type protocolHandlerParams struct {
Spec Spec
Codecs readOnlyCodecs
CompressionPools readOnlyCompressionPools
CompressMinBytes int
BufferPool *bufferPool
ReadMaxBytes int
SendMaxBytes int
RequireConnectProtocolHeader bool
IdempotencyLevel IdempotencyLevel
}
// Handler is the server side of a protocol. HTTP handlers typically support
// multiple protocols, codecs, and compressors.
type protocolHandler interface {
// Methods is the set of HTTP methods the protocol can handle.
Methods() map[string]struct{}
// ContentTypes is the set of HTTP Content-Types that the protocol can
// handle.
ContentTypes() map[string]struct{}
// SetTimeout runs before NewStream. Implementations may inspect the HTTP
// request, parse any timeout set by the client, and return a modified
// context and cancellation function.
//
// If the client didn't send a timeout, SetTimeout should return the
// request's context, a nil cancellation function, and a nil error.
SetTimeout(*http.Request) (context.Context, context.CancelFunc, error)
// CanHandlePayload returns true if the protocol can handle an HTTP request.
// This is called after the request method is validated, so we only need to
// be concerned with the content type/payload specifically.
CanHandlePayload(*http.Request, string) bool
// NewConn constructs a HandlerConn for the message exchange.
NewConn(http.ResponseWriter, *http.Request) (handlerConnCloser, bool)
}
// ClientParams are the arguments provided to a Protocol's NewClient method,
// bundled into a struct to allow backward-compatible argument additions.
// Protocol implementations should take care to use the supplied Spec rather
// than constructing their own, since new fields may have been added.
type protocolClientParams struct {
CompressionName string
CompressionPools readOnlyCompressionPools
Codec Codec
CompressMinBytes int
HTTPClient HTTPClient
URL *url.URL
BufferPool *bufferPool
ReadMaxBytes int
SendMaxBytes int
EnableGet bool
GetURLMaxBytes int
GetUseFallback bool
// The gRPC family of protocols always needs access to a Protobuf codec to
// marshal and unmarshal errors.
Protobuf Codec
}
// Client is the client side of a protocol. HTTP clients typically use a single
// protocol, codec, and compressor to send requests.
type protocolClient interface {
// Peer describes the server for the RPC.
Peer() Peer
// WriteRequestHeader writes any protocol-specific request headers.
WriteRequestHeader(StreamType, http.Header)
// NewConn constructs a StreamingClientConn for the message exchange.
//
// Implementations should assume that the supplied HTTP headers have already
// been populated by WriteRequestHeader. When constructing a stream for a
// unary call, implementations may assume that the Sender's Send and Close
// methods return before the Receiver's Receive or Close methods are called.
NewConn(context.Context, Spec, http.Header) streamingClientConn
}
// streamingClientConn extends StreamingClientConn with a method for registering
// a hook when the HTTP request is actually sent.
type streamingClientConn interface {
StreamingClientConn
onRequestSend(fn func(*http.Request))
}
// errorTranslatingHandlerConnCloser wraps a handlerConnCloser to ensure that
// we always return coded errors to users and write coded errors to the
// network.
//
// It's used in protocol implementations.
type errorTranslatingHandlerConnCloser struct {
handlerConnCloser
toWire func(error) error
fromWire func(error) error
}
func (hc *errorTranslatingHandlerConnCloser) Send(msg any) error {
return hc.fromWire(hc.handlerConnCloser.Send(msg))
}
func (hc *errorTranslatingHandlerConnCloser) Receive(msg any) error {
return hc.fromWire(hc.handlerConnCloser.Receive(msg))
}
func (hc *errorTranslatingHandlerConnCloser) Close(err error) error {
closeErr := hc.handlerConnCloser.Close(hc.toWire(err))
return hc.fromWire(closeErr)
}
func (hc *errorTranslatingHandlerConnCloser) getHTTPMethod() string {
if methoder, ok := hc.handlerConnCloser.(interface{ getHTTPMethod() string }); ok {
return methoder.getHTTPMethod()
}
return http.MethodPost
}
// errorTranslatingClientConn wraps a StreamingClientConn to make sure that we always
// return coded errors from clients.
//
// It's used in protocol implementations.
type errorTranslatingClientConn struct {
streamingClientConn
fromWire func(error) error
}
func (cc *errorTranslatingClientConn) Send(msg any) error {
return cc.fromWire(cc.streamingClientConn.Send(msg))
}
func (cc *errorTranslatingClientConn) Receive(msg any) error {
return cc.fromWire(cc.streamingClientConn.Receive(msg))
}
func (cc *errorTranslatingClientConn) CloseRequest() error {
return cc.fromWire(cc.streamingClientConn.CloseRequest())
}
func (cc *errorTranslatingClientConn) CloseResponse() error {
return cc.fromWire(cc.streamingClientConn.CloseResponse())
}
func (cc *errorTranslatingClientConn) onRequestSend(fn func(*http.Request)) {
cc.streamingClientConn.onRequestSend(fn)
}
// wrapHandlerConnWithCodedErrors ensures that we (1) automatically code
// context-related errors correctly when writing them to the network, and (2)
// return *Errors from all exported APIs.
func wrapHandlerConnWithCodedErrors(conn handlerConnCloser) handlerConnCloser {
return &errorTranslatingHandlerConnCloser{
handlerConnCloser: conn,
toWire: wrapIfContextError,
fromWire: wrapIfUncoded,
}
}
// wrapClientConnWithCodedErrors ensures that we always return *Errors from
// public APIs.
func wrapClientConnWithCodedErrors(conn streamingClientConn) streamingClientConn {
return &errorTranslatingClientConn{
streamingClientConn: conn,
fromWire: wrapIfUncoded,
}
}
func mappedMethodHandlers(handlers []protocolHandler) map[string][]protocolHandler {
methodHandlers := make(map[string][]protocolHandler)
for _, handler := range handlers {
for method := range handler.Methods() {
methodHandlers[method] = append(methodHandlers[method], handler)
}
}
return methodHandlers
}
func sortedAcceptPostValue(handlers []protocolHandler) string {
contentTypes := make(map[string]struct{})
for _, handler := range handlers {
for contentType := range handler.ContentTypes() {
contentTypes[contentType] = struct{}{}
}
}
accept := make([]string, 0, len(contentTypes))
for ct := range contentTypes {
accept = append(accept, ct)
}
sort.Strings(accept)
return strings.Join(accept, ", ")
}
func sortedAllowMethodValue(handlers []protocolHandler) string {
methods := make(map[string]struct{})
for _, handler := range handlers {
for method := range handler.Methods() {
methods[method] = struct{}{}
}
}
allow := make([]string, 0, len(methods))
for ct := range methods {
allow = append(allow, ct)
}
sort.Strings(allow)
return strings.Join(allow, ", ")
}
func isCommaOrSpace(c rune) bool {
return c == ',' || c == ' '
}
func discard(reader io.Reader) (int64, error) {
if lr, ok := reader.(*io.LimitedReader); ok {
return io.Copy(io.Discard, lr)
}
// We don't want to get stuck throwing data away forever, so limit how much
// we're willing to do here.
lr := &io.LimitedReader{R: reader, N: discardLimit}
return io.Copy(io.Discard, lr)
}
// negotiateCompression determines and validates the request compression and
// response compression using the available compressors and protocol-specific
// Content-Encoding and Accept-Encoding headers.
func negotiateCompression( //nolint:nonamedreturns
availableCompressors readOnlyCompressionPools,
sent, accept string,
) (requestCompression, responseCompression string, clientVisibleErr *Error) {
requestCompression = compressionIdentity
if sent != "" && sent != compressionIdentity {
// We default to identity, so we only care if the client sends something
// other than the empty string or compressIdentity.
if availableCompressors.Contains(sent) {
requestCompression = sent
} else {
// To comply with
// https://github.com/grpc/grpc/blob/master/doc/compression.md and the
// Connect protocol, we should return CodeUnimplemented and specify
// acceptable compression(s) (in addition to setting the a
// protocol-specific accept-encoding header).
return "", "", errorf(
CodeUnimplemented,
"unknown compression %q: supported encodings are %v",
sent, availableCompressors.CommaSeparatedNames(),
)
}
}
// Support asymmetric compression. This logic follows
// https://github.com/grpc/grpc/blob/master/doc/compression.md and common
// sense.
responseCompression = requestCompression
// If we're not already planning to compress the response, check whether the
// client requested a compression algorithm we support.
if responseCompression == compressionIdentity && accept != "" {
for _, name := range strings.FieldsFunc(accept, isCommaOrSpace) {
if availableCompressors.Contains(name) {
// We found a mutually supported compression algorithm. Unlike standard
// HTTP, there's no preference weighting, so can bail out immediately.
responseCompression = name
break
}
}
}
return requestCompression, responseCompression, nil
}
// checkServerStreamsCanFlush ensures that bidi and server streaming handlers
// have received an http.ResponseWriter that implements http.Flusher, since
// they must flush data after sending each message.
func checkServerStreamsCanFlush(spec Spec, responseWriter http.ResponseWriter) *Error {
requiresFlusher := (spec.StreamType & StreamTypeServer) == StreamTypeServer
if _, flushable := responseWriter.(http.Flusher); requiresFlusher && !flushable {
return NewError(CodeInternal, fmt.Errorf("%T does not implement http.Flusher", responseWriter))
}
return nil
}
func flushResponseWriter(w http.ResponseWriter) {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
func canonicalizeContentType(contentType string) string {
// Typically, clients send Content-Type in canonical form, without
// parameters. In those cases, we'd like to avoid parsing and
// canonicalization overhead.
//
// See https://www.rfc-editor.org/rfc/rfc2045.html#section-5.1 for a full
// grammar.
var slashes int
for _, r := range contentType {
switch {
case r >= 'a' && r <= 'z':
case r == '.' || r == '+' || r == '-':
case r == '/':
slashes++
default:
return canonicalizeContentTypeSlow(contentType)
}
}
if slashes == 1 {
return contentType
}
return canonicalizeContentTypeSlow(contentType)
}
func canonicalizeContentTypeSlow(contentType string) string {
base, params, err := mime.ParseMediaType(contentType)
if err != nil {
return contentType
}
// According to RFC 9110 Section 8.3.2, the charset parameter value should be treated as case-insensitive.
// mime.FormatMediaType canonicalizes parameter names, but not parameter values,
// because the case sensitivity of a parameter value depends on its semantics.
// Therefore, the charset parameter value should be canonicalized here.
// ref.) https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.2
if charset, ok := params["charset"]; ok {
params["charset"] = strings.ToLower(charset)
}
return mime.FormatMediaType(base, params)
}
func httpToCode(httpCode int) Code {
// https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
// Note that this is NOT the inverse of the gRPC-to-HTTP or Connect-to-HTTP
// mappings.
// Literals are easier to compare to the specification (vs named
// constants).
switch httpCode {
case 400:
return CodeInternal
case 401:
return CodeUnauthenticated
case 403:
return CodePermissionDenied
case 404:
return CodeUnimplemented
case 429:
return CodeUnavailable
case 502, 503, 504:
return CodeUnavailable
default:
return CodeUnknown
}
}
+1449
View File
File diff suppressed because it is too large Load Diff
+1010
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2021-2025 The Connect Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package connect
import (
"context"
"net/http"
)
// recoverHandlerInterceptor lets handlers trap panics, perform side effects
// (like emitting logs or metrics), and present a friendlier error message to
// clients.
type recoverHandlerInterceptor struct {
Interceptor
handle func(context.Context, Spec, http.Header, any) error
}
func (i *recoverHandlerInterceptor) WrapUnary(next UnaryFunc) UnaryFunc {
return func(ctx context.Context, req AnyRequest) (_ AnyResponse, retErr error) {
if req.Spec().IsClient {
return next(ctx, req)
}
defer func() {
if r := recover(); r != nil {
// net/http checks for ErrAbortHandler with ==, so we should too.
if r == http.ErrAbortHandler { //nolint:errorlint,goerr113
panic(r) //nolint:forbidigo
}
retErr = i.handle(ctx, req.Spec(), req.Header(), r)
}
}()
res, err := next(ctx, req)
return res, err
}
}
func (i *recoverHandlerInterceptor) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc {
return func(ctx context.Context, conn StreamingHandlerConn) (retErr error) {
defer func() {
if r := recover(); r != nil {
// net/http checks for ErrAbortHandler with ==, so we should too.
if r == http.ErrAbortHandler { //nolint:errorlint,goerr113
panic(r) //nolint:forbidigo
}
retErr = i.handle(ctx, conn.Spec(), conn.RequestHeader(), r)
}
}()
err := next(ctx, conn)
return err
}
}
+13
View File
@@ -0,0 +1,13 @@
---
blocked:
modules:
- github.com/docker/secrets-engine/runtime:
reason: "Forbidden dependency"
- github.com/docker/secrets-engine/injector:
reason: "Forbidden dependency"
- github.com/docker/secrets-engine/plugin:
reason: "Forbidden dependency"
- github.com/docker/secrets-engine/plugins/pass:
reason: "Forbidden dependency"
- github.com/docker/secrets-engine/store:
reason: "Forbidden dependency"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Docker, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+355
View File
@@ -0,0 +1,355 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"connectrpc.com/connect"
"github.com/docker/secrets-engine/client/dockerhub"
"github.com/docker/secrets-engine/x/api"
healthv1 "github.com/docker/secrets-engine/x/api/health/v1"
"github.com/docker/secrets-engine/x/api/health/v1/healthv1connect"
pluginsv1 "github.com/docker/secrets-engine/x/api/plugins/v1"
"github.com/docker/secrets-engine/x/api/plugins/v1/pluginsv1connect"
"github.com/docker/secrets-engine/x/api/resolver"
"github.com/docker/secrets-engine/x/secrets"
)
type (
Envelope = secrets.Envelope
ID = secrets.ID
Pattern = secrets.Pattern
AuthorizeResponse = secrets.AuthorizeResponse
DaemonVersion = api.DaemonVersion
)
var (
ParseID = secrets.ParseID
MustParseID = secrets.MustParseID
ParsePattern = secrets.ParsePattern
MustParsePattern = secrets.MustParsePattern
ErrSecretNotFound = secrets.ErrNotFound
ErrAccessDenied = secrets.ErrAccessDenied
)
var _ secrets.Resolver = &client{}
type Option func(c *config) error
func WithSocketPath(path string) Option {
return func(s *config) error {
if path == "" {
return errors.New("no path provided")
}
if s.dialContext != nil {
return errors.New("cannot set socket path and dial")
}
s.socketPath = path
s.dialContext = dialFromPath(path)
return nil
}
}
func WithDialContext(dialContext func(ctx context.Context, network, addr string) (net.Conn, error)) Option {
return func(s *config) error {
if s.dialContext != nil {
return errors.New("cannot set socket path and dial")
}
s.dialContext = dialContext
return nil
}
}
// WithTimeout overrides the request timeout of the client.
//
// It is useful to set if there are hard-limits to when the client must wait
// for the server to accept the request.
//
// A timeout of 0 means no request timeout will be applied.
// Negative durations are not allowed and will result in an error.
func WithTimeout(timeout time.Duration) Option {
return func(s *config) error {
if timeout < 0 {
return errors.New("request timeout duration cannot be negative")
}
s.requestTimeout = timeout
return nil
}
}
// WithResponseTimeout overrides the response header timeout of the client.
// By default no timeout is applied, because a request may block on user
// interaction. 0 means no timeout; negative durations return an error.
func WithResponseTimeout(responseTimeout time.Duration) Option {
return func(s *config) error {
if responseTimeout < 0 {
return errors.New("response timeout duration cannot be negative")
}
s.responseTimeout = responseTimeout
return nil
}
}
type dial func(ctx context.Context, network, addr string) (net.Conn, error)
type config struct {
socketPath string
dialContext dial
requestTimeout time.Duration
responseTimeout time.Duration
}
var (
_ Client = &client{}
_ PluginManagement = &client{}
)
type client struct {
socketPath string
resolverClient secrets.Resolver
engineClient pluginsv1connect.PluginManagementServiceClient
versionClient healthv1connect.VersionServiceClient
authorizerClient secrets.Authorizer
}
// Authorize decides access to the patterns. The decision holds until the
// response expiry. A zero expiry means the decision never expires.
func (c client) Authorize(ctx context.Context, patterns ...secrets.Pattern) (secrets.AuthorizeResponse, error) {
resp, err := c.authorizerClient.Authorize(ctx, patterns...)
if isDialError(err) {
return secrets.AuthorizeResponse{}, c.connectError(err)
}
if err != nil {
return secrets.AuthorizeResponse{}, err
}
return resp, nil
}
func (c client) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) {
envelopes, err := c.resolverClient.GetSecrets(ctx, pattern)
if isDialError(err) {
return nil, c.connectError(err)
}
if err != nil {
return nil, err
}
return envelopes, nil
}
func (c client) HubAuth(opts ...dockerhub.Option) dockerhub.ClientAuth {
return dockerhub.New(c, opts...)
}
func (c client) Version(ctx context.Context) (DaemonVersion, error) {
resp, err := c.versionClient.GetVersion(ctx, connect.NewRequest(healthv1.GetVersionRequest_builder{}.Build()))
if isDialError(err) {
return DaemonVersion{}, c.connectError(err)
}
if err != nil {
return DaemonVersion{}, err
}
ver, err := api.NewVersion(resp.Msg.GetVersion())
if err != nil {
return DaemonVersion{}, fmt.Errorf("parsing daemon version %q: %w", resp.Msg.GetVersion(), err)
}
return DaemonVersion{Version: ver, Date: resp.Msg.GetDate(), CommitHash: resp.Msg.GetCommitHash()}, nil
}
// Client is the interface for interacting with the secrets engine daemon.
type Client interface {
secrets.Resolver
secrets.Authorizer
// Version returns the name and version reported by the daemon.
Version(ctx context.Context) (DaemonVersion, error)
// HubAuth returns a Docker Hub authentication accessor backed by this client.
HubAuth(opts ...dockerhub.Option) dockerhub.ClientAuth
}
type PluginManagement interface {
ListPlugins(ctx context.Context) ([]PluginInfo, error)
EnablePlugin(ctx context.Context, name string) error
DisablePlugin(ctx context.Context, name string) error
}
func PluginManagementFromClient(c Client) (PluginManagement, error) {
m, ok := c.(PluginManagement)
if !ok {
return nil, errors.New("client does not implement PluginManagement")
}
return m, nil
}
func isDialError(err error) bool {
if err == nil {
return false
}
var oe *net.OpError
if errors.As(err, &oe) && (oe.Op == "dial" || oe.Op == "connect") {
return true
}
return false
}
func (c client) connectError(err error) error {
if errors.Is(err, context.Canceled) {
return err
}
return &ConnectError{Reason: connectReason(err), SocketPath: c.socketPath, Err: err}
}
// New creates a client that connects to [api.StandaloneSocketPath] by default.
// To connect to Docker Desktop, use WithSocketPath(api.DesktopSocketPath()).
func New(options ...Option) (Client, error) {
cfg := &config{
requestTimeout: api.DefaultClientRequestTimeout,
responseTimeout: api.DefaultClientResponseHeaderTimeout,
}
for _, opt := range options {
if err := opt(cfg); err != nil {
return nil, err
}
}
if cfg.dialContext == nil {
cfg.socketPath = api.StandaloneSocketPath()
cfg.dialContext = dialFromPath(cfg.socketPath)
}
c := &http.Client{
Transport: &http.Transport{
// re-use the same connection to the runtime, this speeds up subsequent
// calls.
MaxConnsPerHost: api.DefaultClientMaxConnsPerHost,
MaxIdleConnsPerHost: api.DefaultClientMaxIdleConnsPerHost,
// keep the connection alive (good for long-lived clients)
IdleConnTimeout: api.DefaultClientIdleConnTimeout,
// no timeout by default; override with [WithResponseTimeout]
ResponseHeaderTimeout: cfg.responseTimeout,
TLSHandshakeTimeout: api.DefaultClientTLSHandshakeTimeout,
DialContext: cfg.dialContext,
DisableKeepAlives: false,
DisableCompression: false,
ForceAttemptHTTP2: true,
},
// by default Timeout will be 0 (meaning no timeout)
// it can be overwritten with [WithTimeout]
Timeout: cfg.requestTimeout,
}
return &client{
socketPath: cfg.socketPath,
resolverClient: resolver.NewResolverClient(c),
engineClient: pluginsv1connect.NewPluginManagementServiceClient(c, "http://unix"),
versionClient: healthv1connect.NewVersionServiceClient(c, "http://unix"),
authorizerClient: resolver.NewAuthorizerClient(c),
}, nil
}
func (c client) ListPlugins(ctx context.Context) ([]PluginInfo, error) {
req := connect.NewRequest(pluginsv1.ListPluginsRequest_builder{}.Build())
resp, err := c.engineClient.ListPlugins(ctx, req)
if isDialError(err) {
return nil, c.connectError(err)
}
if err != nil {
return nil, err
}
var result []PluginInfo
for _, item := range resp.Msg.GetPlugins() {
name, err := api.NewName(item.GetName())
if err != nil {
continue
}
version, err := api.NewVersion(item.GetVersion())
if err != nil {
continue
}
info := PluginInfo{
Name: name,
Version: version,
Disabled: item.GetDisabled(),
External: item.GetExternal(),
Configurable: item.GetConfigurable(),
RunStatus: item.GetRunStatus(),
StatusMessage: item.GetStatusMessage(),
}
switch item.WhichMetadata() {
case pluginsv1.Plugin_SecretsProvider_case:
pattern, err := secrets.ParsePattern(item.GetSecretsProvider().GetPattern())
if err != nil {
continue
}
info.SecretsProvider = &SecretsProviderMetadata{Pattern: pattern}
case pluginsv1.Plugin_AccessControlModule_case:
info.AccessControlModule = &AccessControlModuleMetadata{}
}
result = append(result, info)
}
return result, nil
}
func (c client) EnablePlugin(ctx context.Context, name string) error {
r := pluginsv1.EnablePluginRequest_builder{}.Build()
r.SetName(name)
_, err := c.engineClient.EnablePlugin(ctx, connect.NewRequest(r))
if isDialError(err) {
return c.connectError(err)
}
return err
}
func (c client) DisablePlugin(ctx context.Context, name string) error {
r := pluginsv1.DisablePluginRequest_builder{}.Build()
r.SetName(name)
_, err := c.engineClient.DisablePlugin(ctx, connect.NewRequest(r))
if isDialError(err) {
return c.connectError(err)
}
return err
}
type PluginInfo struct {
Name api.Name
Version api.Version
Disabled bool
External bool
Configurable bool
RunStatus pluginsv1.RunStatus
StatusMessage string
SecretsProvider *SecretsProviderMetadata
AccessControlModule *AccessControlModuleMetadata
}
type AccessControlModuleMetadata struct{}
type SecretsProviderMetadata struct {
Pattern secrets.Pattern
}
func dialFromPath(path string) dial {
return func(ctx context.Context, _, _ string) (net.Conn, error) {
d := &net.Dialer{}
return d.DialContext(ctx, "unix", path)
}
}
+415
View File
@@ -0,0 +1,415 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package dockerhub reads Docker Hub access tokens and account profiles from
// the secrets engine. Obtain a [ClientAuth] from the client's HubAuth method,
// or from any [secrets.Resolver] via [New].
package dockerhub
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/docker/secrets-engine/client/realms"
"github.com/docker/secrets-engine/x/secrets"
)
var (
// ErrNoSession means no Docker Hub credential is stored for the account.
ErrNoSession = errors.New("user is not authenticated for this application")
// ErrNoDefaultProfile means no account is set as the default. It wraps
// [ErrNoSession].
ErrNoDefaultProfile = fmt.Errorf("no default account profile set: %w", ErrNoSession)
// ErrMissingAccessToken means the stored credential has no access token.
// It wraps [ErrNoSession].
ErrMissingAccessToken error = &noSessionError{"no access token in payload"}
// ErrInvalidAccessToken means the stored access token is not a JWT. It
// wraps [ErrNoSession].
ErrInvalidAccessToken error = &noSessionError{"access token is not a JWT"}
)
type noSessionError struct{ msg string }
func (e *noSessionError) Error() string { return e.msg }
func (*noSessionError) Unwrap() error { return ErrNoSession }
var (
defaultProfileKey = secrets.MustParsePattern("default")
singleEntryKey = secrets.MustParsePattern("*")
)
// UserSession is a stored Docker Hub credential.
type UserSession struct {
// AccessToken is a Docker Hub issued JWT.
AccessToken string `json:"access_token"`
// Claims are zero when the payload carries none.
Claims Claims `json:"claims"`
}
// Expired reports whether the access token has expired. It is
// ExpiresWithin(0).
func (s UserSession) Expired() bool {
return s.ExpiresWithin(0)
}
// ExpiresWithin reports whether the access token will expire within the next given duration.
// Sessions without [Claims.ExpiresAt] report as expired by default.
func (s UserSession) ExpiresWithin(leeway time.Duration) bool {
exp := s.Claims.ExpiresAt
if exp == nil || exp.IsZero() {
return true
}
return !time.Now().Add(leeway).Before(exp.Time)
}
// Claims are the claims of a Docker Hub access token.
type Claims struct {
Issuer string `json:"iss,omitempty"`
Subject string `json:"sub,omitempty"`
Audience Audience `json:"aud,omitempty"`
ExpiresAt *NumericDate `json:"exp,omitempty"`
NotBefore *NumericDate `json:"nbf,omitempty"`
IssuedAt *NumericDate `json:"iat,omitempty"`
ID string `json:"jti,omitempty"`
// Scope is a space-delimited list of granted scopes.
Scope string `json:"scope,omitempty"`
// AppName is the Docker client application the token was issued to.
AppName string `json:"app_name"`
UUID string `json:"uuid"`
// Source is formatted as `docker_{type}|{id}`.
Source string `json:"source"`
SessionID string `json:"session_id"`
ClientID string `json:"client_id,omitempty"`
ClientName string `json:"client_name,omitempty"`
Email string `json:"email"`
Username string `json:"username"`
}
// NumericDate is an RFC 7519 numeric date: UNIX epoch seconds. It marshals
// truncated to whole seconds.
type NumericDate struct {
time.Time
}
func (d NumericDate) MarshalJSON() ([]byte, error) {
return []byte(strconv.FormatInt(d.Unix(), 10)), nil
}
func (d *NumericDate) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var epoch float64
if err := json.Unmarshal(data, &epoch); err != nil {
return fmt.Errorf("parse numeric date: %w", err)
}
if math.Abs(epoch) > 1e15 {
return fmt.Errorf("parse numeric date: %v is out of range", epoch)
}
seconds, fraction := math.Modf(epoch)
d.Time = time.Unix(int64(seconds), int64(fraction*float64(time.Second)))
return nil
}
// Audience is the "aud" claim: a single string or an array of strings.
type Audience []string
func (a *Audience) UnmarshalJSON(data []byte) error {
var value any
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("parse audience: %w", err)
}
switch v := value.(type) {
case nil:
case string:
*a = Audience{v}
case []any:
audience := make(Audience, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if !ok {
return fmt.Errorf("parse audience: unexpected element type %T", item)
}
audience = append(audience, s)
}
*a = audience
default:
return fmt.Errorf("parse audience: unexpected type %T", value)
}
return nil
}
// Profile describes a signed-in Docker Hub account.
type Profile struct {
// UserID is the secret ID where the account's credential is stored.
UserID string `json:"user_id"`
// OriginalSignInApp is the Docker client application the user signed in from.
OriginalSignInApp string `json:"original_sign_in_app"`
Username string `json:"username"`
Email string `json:"email"`
SignInDate time.Time `json:"sign_in_date"`
}
// parseUserSession decodes a docker/auth/hub/** envelope into a [UserSession].
func parseUserSession(envelope secrets.Envelope) (UserSession, error) {
var session UserSession
if err := json.Unmarshal(envelope.Value, &session); err != nil {
return UserSession{}, fmt.Errorf("decode user session: %w", err)
}
if session.AccessToken == "" {
return UserSession{}, fmt.Errorf("decode user session: %w", ErrMissingAccessToken)
}
if err := checkJWT(session.AccessToken); err != nil {
return UserSession{}, fmt.Errorf("decode user session: %w: %w", ErrInvalidAccessToken, err)
}
return session, nil
}
func checkJWT(token string) error {
segments := strings.Split(token, ".")
if len(segments) != 3 {
return fmt.Errorf("expected 3 dot-separated segments, got %d", len(segments))
}
var header struct {
Alg string `json:"alg"`
}
if err := decodeSegment(segments[0], &header); err != nil {
return fmt.Errorf("header: %w", err)
}
if header.Alg == "" {
return errors.New("header: missing alg")
}
var payload map[string]json.RawMessage
if err := decodeSegment(segments[1], &payload); err != nil {
return fmt.Errorf("payload: %w", err)
}
if payload == nil {
return errors.New("payload: not a JSON object")
}
if segments[2] == "" {
return errors.New("missing signature")
}
if _, err := base64.RawURLEncoding.DecodeString(segments[2]); err != nil {
return fmt.Errorf("signature: %w", err)
}
return nil
}
func decodeSegment(segment string, v any) error {
data, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// parseProfile decodes a docker/auth/metadata/hub/** envelope into a [Profile].
func parseProfile(envelope secrets.Envelope) (Profile, error) {
var profile Profile
if err := json.Unmarshal(envelope.Value, &profile); err != nil {
return Profile{}, fmt.Errorf("decode profile metadata: %w", err)
}
if profile.UserID == "" {
return Profile{}, errors.New("decode profile metadata: no user ID in payload")
}
return profile, nil
}
// ClientAuth reads Docker Hub authentication state from the secrets engine.
type ClientAuth interface {
// ListProfiles returns the profiles of all signed-in accounts.
ListProfiles(ctx context.Context) ([]Profile, error)
// GetDefaultProfile returns the default account's profile, or
// [ErrNoDefaultProfile] when no default is set.
GetDefaultProfile(ctx context.Context) (Profile, error)
// GetDefaultSession returns the default account's session:
// [ErrNoDefaultProfile] when no default is set, [ErrNoSession] when its
// credential is missing.
GetDefaultSession(ctx context.Context) (UserSession, error)
// GetSession returns the session for username, or [ErrNoSession].
GetSession(ctx context.Context, username string) (UserSession, error)
}
// Option configures a [ClientAuth].
type Option func(*config)
type config struct {
accounts secrets.Pattern
profiles secrets.Pattern
defaultEntry secrets.Pattern
accountEntry secrets.Pattern
}
// Staging switches the lookup to the Docker Hub staging realms.
func Staging() Option {
return func(c *config) {
c.accounts = realms.DockerHubStagingAuthentication
c.profiles = realms.DockerHubStagingAuthenticationMetadata
}
}
func newConfig(opts []Option) config {
cfg := config{
accounts: realms.DockerHubAuthentication,
profiles: realms.DockerHubAuthenticationMetadata,
}
for _, opt := range opts {
opt(&cfg)
}
cfg.defaultEntry = mustExpand(cfg.profiles, defaultProfileKey)
cfg.accountEntry = mustExpand(cfg.accounts, singleEntryKey)
return cfg
}
func mustExpand(realm, key secrets.Pattern) secrets.Pattern {
expanded, err := realm.ExpandPattern(key)
if err != nil {
panic(fmt.Sprintf("dockerhub: expand %s in %s: %v", key, realm, err))
}
return expanded
}
var _ ClientAuth = clientAuth{}
type clientAuth struct {
engine secrets.Resolver
cfg config
}
// New returns a [ClientAuth] backed by engine. It panics on a nil engine.
func New(engine secrets.Resolver, opts ...Option) ClientAuth {
if engine == nil {
panic("dockerhub: secrets engine client is required")
}
return clientAuth{engine: engine, cfg: newConfig(opts)}
}
func (c clientAuth) ListProfiles(ctx context.Context) ([]Profile, error) {
envelopes, err := c.engine.GetSecrets(ctx, c.cfg.profiles)
if errors.Is(err, secrets.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("list account profiles: %w", err)
}
var profiles []Profile
var errs []error
seen := make(map[string]bool, len(envelopes))
for _, envelope := range envelopes {
// The default entry duplicates an account's profile.
if envelope.ID != nil && c.cfg.defaultEntry.Match(envelope.ID) {
continue
}
profile, err := parseProfile(envelope)
if err != nil {
errs = append(errs, err)
continue
}
if seen[profile.UserID] {
continue
}
seen[profile.UserID] = true
profiles = append(profiles, profile)
}
if len(profiles) == 0 && len(errs) > 0 {
return nil, errors.Join(errs...)
}
return profiles, nil
}
func (c clientAuth) GetDefaultProfile(ctx context.Context) (Profile, error) {
envelopes, err := c.engine.GetSecrets(ctx, c.cfg.defaultEntry)
if errors.Is(err, secrets.ErrNotFound) {
return Profile{}, ErrNoDefaultProfile
}
if err != nil {
return Profile{}, fmt.Errorf("retrieve default account metadata: %w", err)
}
return parseFirst(envelopes, parseProfile, ErrNoDefaultProfile)
}
func (c clientAuth) GetDefaultSession(ctx context.Context) (UserSession, error) {
profile, err := c.GetDefaultProfile(ctx)
if err != nil {
return UserSession{}, err
}
// Require a wildcard-free ID naming one account entry in the accounts
// realm, so a tampered profile cannot address an arbitrary secret.
id, err := secrets.ParseID(profile.UserID)
if err != nil {
return UserSession{}, fmt.Errorf("default profile user id: %w", err)
}
if !c.cfg.accountEntry.Match(id) {
return UserSession{}, fmt.Errorf("default profile user id %q is not an account entry in the %s realm", profile.UserID, c.cfg.accounts)
}
return c.getSession(ctx, exactPattern(id))
}
func (c clientAuth) GetSession(ctx context.Context, username string) (UserSession, error) {
if strings.Contains(username, "/") {
return UserSession{}, fmt.Errorf("invalid username %q: must not contain '/'", username)
}
user, err := secrets.ParseID(username)
if err != nil {
return UserSession{}, fmt.Errorf("invalid username %q: %w", username, err)
}
id, err := c.cfg.accounts.ExpandID(user)
if err != nil {
return UserSession{}, err
}
return c.getSession(ctx, exactPattern(id))
}
func (c clientAuth) getSession(ctx context.Context, pattern secrets.Pattern) (UserSession, error) {
envelopes, err := c.engine.GetSecrets(ctx, pattern)
if errors.Is(err, secrets.ErrNotFound) {
return UserSession{}, ErrNoSession
}
if err != nil {
return UserSession{}, fmt.Errorf("retrieve user access token: %w", err)
}
return parseFirst(envelopes, parseUserSession, ErrNoSession)
}
func exactPattern(id secrets.ID) secrets.Pattern {
return secrets.MustParsePattern(id.String())
}
// parseFirst returns the first envelope that parses. It returns notFound when
// there are no envelopes, and the joined decode errors when none parses.
func parseFirst[T any](envelopes []secrets.Envelope, parse func(secrets.Envelope) (T, error), notFound error) (T, error) {
var zero T
var errs []error
for _, envelope := range envelopes {
v, err := parse(envelope)
if err != nil {
errs = append(errs, err)
continue
}
return v, nil
}
if len(errs) == 0 {
return zero, notFound
}
return zero, errors.Join(errs...)
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package client
import "syscall"
// errConnRefused is returned when dialing a socket nothing listens on.
const errConnRefused = syscall.ECONNREFUSED
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import "syscall"
// errConnRefused is WSAECONNREFUSED; Winsock never returns syscall.ECONNREFUSED.
const errConnRefused = syscall.Errno(10061)
+173
View File
@@ -0,0 +1,173 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"context"
"errors"
"fmt"
"io/fs"
"net"
"path/filepath"
"github.com/docker/secrets-engine/x/api"
)
var (
ErrSecretsEngineNotRunning = errors.New("secrets engine is not running")
ErrSecretsEnginePermissionDenied = errors.New("permission denied connecting to the secrets engine")
ErrSecretsEngineTimeout = errors.New("secrets engine did not respond in time")
)
type ConnectReason int
const (
ReasonUnknown ConnectReason = iota
ReasonNotRunning
ReasonPermissionDenied
ReasonTimeout
)
func (r ConnectReason) String() string {
switch r {
case ReasonNotRunning:
return "not running"
case ReasonPermissionDenied:
return "permission denied"
case ReasonTimeout:
return "timeout"
default:
return "unknown"
}
}
func (r ConnectReason) sentinel() error {
switch r {
case ReasonNotRunning:
return ErrSecretsEngineNotRunning
case ReasonPermissionDenied:
return ErrSecretsEnginePermissionDenied
case ReasonTimeout:
return ErrSecretsEngineTimeout
default:
return nil
}
}
type ConnectError struct {
Reason ConnectReason
SocketPath string
Err error
}
func (e *ConnectError) Error() string {
msg := "cannot connect to the secrets engine"
if s := e.Reason.sentinel(); s != nil {
msg = s.Error()
}
if e.SocketPath != "" {
msg += " at " + e.SocketPath
}
if e.Err != nil {
msg += ": " + e.Err.Error()
}
return msg
}
func (e *ConnectError) Unwrap() error { return e.Err }
func (e *ConnectError) Is(target error) bool {
s := e.Reason.sentinel()
return s != nil && target == s
}
const (
desktopNotRunningHint = "Start Docker Desktop and retry: the secrets engine runs as part of it."
desktopTimeoutHint = "The secrets engine did not respond. Restart Docker Desktop and retry."
standaloneNotRunningHint = "Start the standalone secrets engine and retry: nothing is listening on %s."
standaloneTimeoutHint = "The standalone secrets engine on %s did not respond. Restart it and retry."
customNotRunningHint = "Start the secrets engine and retry: nothing is listening on %s."
customTimeoutHint = "The secrets engine on %s did not respond. Restart it and retry."
permissionDeniedHint = "Your user lacks permission to connect to %s. Grant it read and write access to the socket and retry."
)
// Hint returns a user friendly message to help recover from the error.
func (e *ConnectError) Hint() string {
socket := "the secrets engine socket"
if e.SocketPath != "" {
socket = fmt.Sprintf("%q", e.SocketPath)
}
kind := classifySocket(e.SocketPath)
switch e.Reason {
case ReasonNotRunning:
switch kind {
case engineDesktop:
return desktopNotRunningHint
case engineStandalone:
return fmt.Sprintf(standaloneNotRunningHint, socket)
default:
return fmt.Sprintf(customNotRunningHint, socket)
}
case ReasonTimeout:
switch kind {
case engineDesktop:
return desktopTimeoutHint
case engineStandalone:
return fmt.Sprintf(standaloneTimeoutHint, socket)
default:
return fmt.Sprintf(customTimeoutHint, socket)
}
case ReasonPermissionDenied:
return fmt.Sprintf(permissionDeniedHint, socket)
default:
return ""
}
}
type engineKind int
const (
engineCustom engineKind = iota
engineDesktop
engineStandalone
)
func classifySocket(path string) engineKind {
if path == "" {
return engineCustom
}
switch filepath.Clean(path) {
case filepath.Clean(api.DesktopSocketPath()):
return engineDesktop
case filepath.Clean(api.StandaloneSocketPath()):
return engineStandalone
default:
return engineCustom
}
}
func connectReason(err error) ConnectReason {
var ne net.Error
switch {
case errors.Is(err, fs.ErrNotExist), errors.Is(err, errConnRefused):
return ReasonNotRunning
case errors.Is(err, fs.ErrPermission):
return ReasonPermissionDenied
case errors.Is(err, context.DeadlineExceeded), errors.As(err, &ne) && ne.Timeout():
return ReasonTimeout
default:
return ReasonUnknown
}
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package realms exposes the canonical set of Docker realm patterns that
// clients use to scope secret queries.
//
// A realm is a path-pattern contract - not a permission boundary. Clients use
// realms to declare which set of secrets they care about (e.g. Hub auth,
// registry auth, MCP OAuth). Once a realm is established its pattern MUST NOT
// change; clients may pin to a realm and treat it as a stable interface.
//
// The Docker realm hierarchy is:
//
// docker/ – all Docker secrets
// docker/auth/hub/** – Docker Hub authentication (OAuth login)
// docker/auth/hub-staging/** – Docker Hub staging authentication
// docker/auth/registry/docker/** – Docker Registry authentication
// docker/auth/metadata/hub/** – Hub account profile metadata
// docker/mcp/** – MCP-related secrets
// docker/mcp/oauth/** – MCP OAuth credentials
// docker/mcp/oauth-dcr/** – MCP Dynamic Client Registration configs
// docker/sandbox/** – Sandbox-related secrets
// docker/sandbox/oauth/** – Sandbox third-party OAuth tokens
//
// All variables in this package are re-exported from
// github.com/docker/secrets-engine/x/realms and are provided here as a
// stable, versioned surface for external consumers.
package realms
import xrealms "github.com/docker/secrets-engine/x/realms"
// Docker realms all start with `docker/` as the prefix.
//
// Authentication flows done by the Docker CLI, Docker Desktop and Docker related
// products must go through `docker/auth`.
//
// Docker Hub authentication (browser based OAuth login) will be prefixed
// with `docker/auth/hub/<username>`.
//
// Docker Registry authentication will be prefixed with
// `docker/auth/registry/docker/<username>`.
var (
DockerHubAuthentication = xrealms.DockerHubAuthentication
DockerHubStagingAuthentication = xrealms.DockerHubStagingAuthentication
DockerRegistryAuthentication = xrealms.DockerRegistryAuthentication
DockerRegistryStagingAuthentication = xrealms.DockerRegistryStagingAuthentication
)
var (
// DockerHubAuthenticationMetadata holds one profile entry per signed-in
// Docker Hub account, plus a default entry duplicating the default
// account's profile.
DockerHubAuthenticationMetadata = xrealms.DockerHubAuthenticationMetadata
// DockerHubStagingAuthenticationMetadata is the staging variant of
// [DockerHubAuthenticationMetadata].
DockerHubStagingAuthenticationMetadata = xrealms.DockerHubStagingAuthenticationMetadata
)
var (
// DockerMCPDefault is the default realm used for MCP related secrets
DockerMCPDefault = xrealms.DockerMCPDefault
// DockerMCPOAuth is the realm used for all MCP OAuth credentials retrieved by Docker.
DockerMCPOAuth = xrealms.DockerMCPOAuth
// DockerMCPOAuthDCR is the realm used to hold Dynamic Client Registered (DCR)
// OAuth configurations for supported MCP servers.
DockerMCPOAuthDCR = xrealms.DockerMCPOAuthDCR
)
var (
// DockerSandbox is the default realm used for Sandbox related secrets
DockerSandbox = xrealms.DockerSandbox
// DockerSandboxOAuth is the realm used for all Sandbox OAuth credentials
// such as third-party tokens - this does not store the Docker Auth tokens.
DockerSandboxOAuth = xrealms.DockerSandboxOAuth
)
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Docker, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
// DaemonVersion holds version information reported by the daemon.
type DaemonVersion struct {
Version Version
Date string
CommitHash string
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"os"
"path/filepath"
"time"
)
const (
// PluginLaunchedByEngineVar is used to inform engine-launched plugins about their name.
PluginLaunchedByEngineVar = "DOCKER_SECRETS_ENGINE_PLUGIN_LAUNCH_CFG"
// DefaultPluginRegistrationTimeout is the default timeout for plugin registration.
DefaultPluginRegistrationTimeout = 5 * time.Second
// DefaultClientRequestTimeout is the default timeout for clients to handle a request.
DefaultClientRequestTimeout = time.Duration(0) // 0 means no limit
// DefaultClientResponseHeaderTimeout is the default timeout for response
// headers, excluding the body. No limit by default: a request may block
// on user interaction daemon-side. Use [client.WithResponseTimeout] or a
// context deadline for a hard bound.
DefaultClientResponseHeaderTimeout = time.Duration(0) // 0 means no limit
// DefaultClientTLSHandshakeTimeout is the default timeout for clients to handle
// tls handshakes. It should usually be short.
DefaultClientTLSHandshakeTimeout = time.Second
// DefaultClientIdleConnTimeout is the time a connection may stay alive for.
// Clients that are long-lived take advantage of re-using the same connection
// when making subsequent requests. This reduces latency.
DefaultClientIdleConnTimeout = 90 * time.Second
// DefaultClientMaxConnsPerHost is the maximum number of open connections
// to the same host.
DefaultClientMaxConnsPerHost = 100
// DefaultClientMaxIdleConnsPerHost is the maximum number of idle connections
// to the same host. Long-lived clients can re-use a connection from the
// connection pool.
DefaultClientMaxIdleConnsPerHost = 10
)
// DesktopSocketPath returns the Secrets Engine socket path used by Docker Desktop.
//
// - Linux: $XDG_CACHE_HOME/docker-secrets-engine/engine.sock, or
// $HOME/.cache/docker-secrets-engine/engine.sock when XDG_CACHE_HOME is unset.
// - macOS: $HOME/Library/Caches/docker-secrets-engine/engine.sock.
// - Windows: %LOCALAPPDATA%\docker-secrets-engine\engine.sock.
//
// If the user cache directory cannot be determined, it returns
// <temp>/docker-secrets-engine/engine.sock, where <temp> is the directory returned
// by [os.TempDir].
func DesktopSocketPath() string {
if dir, err := os.UserCacheDir(); err == nil {
return filepath.Join(dir, "docker-secrets-engine", "engine.sock")
}
return filepath.Join(os.TempDir(), "docker-secrets-engine", "engine.sock")
}
// DefaultSocketPath returns the Secrets Engine socket path used by Docker Desktop.
//
// Deprecated: Use [DesktopSocketPath] instead.
func DefaultSocketPath() string {
return DesktopSocketPath()
}
func DefaultSecretsEngineDirectory() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "docker-secrets-engine"), nil
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build darwin
package api
import (
"os"
"path/filepath"
)
// StandaloneSocketPath returns the standalone Secrets Engine's listening socket
// path, $HOME/Library/Caches/docker-secrets-engine/daemon.sock.
// If HOME is unset, it uses $TMPDIR/docker-secrets-engine/daemon.sock, or
// /tmp/docker-secrets-engine/daemon.sock when TMPDIR is also unset.
func StandaloneSocketPath() string {
if dir, err := os.UserCacheDir(); err == nil {
return filepath.Join(dir, "docker-secrets-engine", "daemon.sock")
}
return filepath.Join(os.TempDir(), "docker-secrets-engine", "daemon.sock")
}
// DaemonSocketPath returns the standalone Secrets Engine's listening socket path.
//
// Deprecated: Use [StandaloneSocketPath] instead.
func DaemonSocketPath() string {
return StandaloneSocketPath()
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build linux
package api
import (
"fmt"
"os"
)
// StandaloneSocketPath returns the address of the standalone Secrets Engine's
// listening socket, @docker-secrets-engine/<UID>/daemon.sock.
// For example, UID 1000 uses @docker-secrets-engine/1000/daemon.sock.
//
// On Linux it is an abstract Unix domain socket: the address has a leading
// "@", which Go's net package maps to a NUL byte, placing the socket in the
// abstract namespace instead of on the filesystem.
//
// The address is namespaced by the user's UID so daemons run by different
// users on the same host do not collide (the abstract namespace is shared per
// network namespace, not per user as a filesystem path would be).
func StandaloneSocketPath() string {
return fmt.Sprintf("@docker-secrets-engine/%d/daemon.sock", os.Getuid())
}
// DaemonSocketPath returns the address of the standalone Secrets Engine's
// listening socket.
//
// Deprecated: Use [StandaloneSocketPath] instead.
func DaemonSocketPath() string {
return StandaloneSocketPath()
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package api
import (
"os"
"path/filepath"
)
// StandaloneSocketPath returns the standalone Secrets Engine's listening socket
// path, %LOCALAPPDATA%\DockerSecretsEngine\service\daemon.sock.
// If LOCALAPPDATA is unset, it uses
// %USERPROFILE%\AppData\Local\DockerSecretsEngine\service\daemon.sock.
func StandaloneSocketPath() string {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
}
return filepath.Join(base, "DockerSecretsEngine", "service", "daemon.sock")
}
// DaemonSocketPath returns the standalone Secrets Engine's listening socket path.
//
// Deprecated: Use [StandaloneSocketPath] instead.
func DaemonSocketPath() string {
return StandaloneSocketPath()
}
+264
View File
@@ -0,0 +1,264 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.7
// protoc (unknown)
// source: health/v1/api.proto
package healthv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetVersionRequest struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetVersionRequest) Reset() {
*x = GetVersionRequest{}
mi := &file_health_v1_api_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetVersionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetVersionRequest) ProtoMessage() {}
func (x *GetVersionRequest) ProtoReflect() protoreflect.Message {
mi := &file_health_v1_api_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
type GetVersionRequest_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
}
func (b0 GetVersionRequest_builder) Build() *GetVersionRequest {
m0 := &GetVersionRequest{}
b, x := &b0, m0
_, _ = b, x
return m0
}
type GetVersionResponse struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
xxx_hidden_Version *string `protobuf:"bytes,1,opt,name=version"`
xxx_hidden_Date *string `protobuf:"bytes,2,opt,name=date"`
xxx_hidden_CommitHash *string `protobuf:"bytes,3,opt,name=commit_hash,json=commitHash"`
XXX_raceDetectHookData protoimpl.RaceDetectHookData
XXX_presence [1]uint32
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetVersionResponse) Reset() {
*x = GetVersionResponse{}
mi := &file_health_v1_api_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetVersionResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetVersionResponse) ProtoMessage() {}
func (x *GetVersionResponse) ProtoReflect() protoreflect.Message {
mi := &file_health_v1_api_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *GetVersionResponse) GetVersion() string {
if x != nil {
if x.xxx_hidden_Version != nil {
return *x.xxx_hidden_Version
}
return ""
}
return ""
}
func (x *GetVersionResponse) GetDate() string {
if x != nil {
if x.xxx_hidden_Date != nil {
return *x.xxx_hidden_Date
}
return ""
}
return ""
}
func (x *GetVersionResponse) GetCommitHash() string {
if x != nil {
if x.xxx_hidden_CommitHash != nil {
return *x.xxx_hidden_CommitHash
}
return ""
}
return ""
}
func (x *GetVersionResponse) SetVersion(v string) {
x.xxx_hidden_Version = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 3)
}
func (x *GetVersionResponse) SetDate(v string) {
x.xxx_hidden_Date = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 3)
}
func (x *GetVersionResponse) SetCommitHash(v string) {
x.xxx_hidden_CommitHash = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 2, 3)
}
func (x *GetVersionResponse) HasVersion() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 0)
}
func (x *GetVersionResponse) HasDate() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 1)
}
func (x *GetVersionResponse) HasCommitHash() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 2)
}
func (x *GetVersionResponse) ClearVersion() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0)
x.xxx_hidden_Version = nil
}
func (x *GetVersionResponse) ClearDate() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 1)
x.xxx_hidden_Date = nil
}
func (x *GetVersionResponse) ClearCommitHash() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 2)
x.xxx_hidden_CommitHash = nil
}
type GetVersionResponse_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
Version *string
Date *string
CommitHash *string
}
func (b0 GetVersionResponse_builder) Build() *GetVersionResponse {
m0 := &GetVersionResponse{}
b, x := &b0, m0
_, _ = b, x
if b.Version != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 3)
x.xxx_hidden_Version = b.Version
}
if b.Date != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 3)
x.xxx_hidden_Date = b.Date
}
if b.CommitHash != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 2, 3)
x.xxx_hidden_CommitHash = b.CommitHash
}
return m0
}
var File_health_v1_api_proto protoreflect.FileDescriptor
const file_health_v1_api_proto_rawDesc = "" +
"\n" +
"\x13health/v1/api.proto\x12\thealth.v1\"\x13\n" +
"\x11GetVersionRequest\"c\n" +
"\x12GetVersionResponse\x12\x18\n" +
"\aversion\x18\x01 \x01(\tR\aversion\x12\x12\n" +
"\x04date\x18\x02 \x01(\tR\x04date\x12\x1f\n" +
"\vcommit_hash\x18\x03 \x01(\tR\n" +
"commitHash2[\n" +
"\x0eVersionService\x12I\n" +
"\n" +
"GetVersion\x12\x1c.health.v1.GetVersionRequest\x1a\x1d.health.v1.GetVersionResponseB\x99\x01\n" +
"\rcom.health.v1B\bApiProtoP\x01Z9github.com/docker/secrets-engine/x/api/health/v1;healthv1\xa2\x02\x03HXX\xaa\x02\tHealth.V1\xca\x02\tHealth\\V1\xe2\x02\x15Health\\V1\\GPBMetadata\xea\x02\n" +
"Health::V1b\beditionsp\xe8\a"
var file_health_v1_api_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_health_v1_api_proto_goTypes = []any{
(*GetVersionRequest)(nil), // 0: health.v1.GetVersionRequest
(*GetVersionResponse)(nil), // 1: health.v1.GetVersionResponse
}
var file_health_v1_api_proto_depIdxs = []int32{
0, // 0: health.v1.VersionService.GetVersion:input_type -> health.v1.GetVersionRequest
1, // 1: health.v1.VersionService.GetVersion:output_type -> health.v1.GetVersionResponse
1, // [1:2] is the sub-list for method output_type
0, // [0:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_health_v1_api_proto_init() }
func file_health_v1_api_proto_init() {
if File_health_v1_api_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_health_v1_api_proto_rawDesc), len(file_health_v1_api_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_health_v1_api_proto_goTypes,
DependencyIndexes: file_health_v1_api_proto_depIdxs,
MessageInfos: file_health_v1_api_proto_msgTypes,
}.Build()
File_health_v1_api_proto = out.File
file_health_v1_api_proto_goTypes = nil
file_health_v1_api_proto_depIdxs = nil
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
edition = "2023";
package health.v1;
option go_package = "github.com/docker/secrets-engine/x/api/health/v1;healthv1";
service VersionService {
// GetVersion returns the version of the daemon.
rpc GetVersion(GetVersionRequest) returns (GetVersionResponse);
}
message GetVersionRequest {}
message GetVersionResponse {
string version = 1;
string date = 2;
string commit_hash = 3;
}
@@ -0,0 +1,111 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: health/v1/api.proto
package healthv1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
v1 "github.com/docker/secrets-engine/x/api/health/v1"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// VersionServiceName is the fully-qualified name of the VersionService service.
VersionServiceName = "health.v1.VersionService"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// VersionServiceGetVersionProcedure is the fully-qualified name of the VersionService's GetVersion
// RPC.
VersionServiceGetVersionProcedure = "/health.v1.VersionService/GetVersion"
)
// VersionServiceClient is a client for the health.v1.VersionService service.
type VersionServiceClient interface {
// GetVersion returns the version of the daemon.
GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error)
}
// NewVersionServiceClient constructs a client for the health.v1.VersionService service. By default,
// it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and
// sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC()
// or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewVersionServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) VersionServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
versionServiceMethods := v1.File_health_v1_api_proto.Services().ByName("VersionService").Methods()
return &versionServiceClient{
getVersion: connect.NewClient[v1.GetVersionRequest, v1.GetVersionResponse](
httpClient,
baseURL+VersionServiceGetVersionProcedure,
connect.WithSchema(versionServiceMethods.ByName("GetVersion")),
connect.WithClientOptions(opts...),
),
}
}
// versionServiceClient implements VersionServiceClient.
type versionServiceClient struct {
getVersion *connect.Client[v1.GetVersionRequest, v1.GetVersionResponse]
}
// GetVersion calls health.v1.VersionService.GetVersion.
func (c *versionServiceClient) GetVersion(ctx context.Context, req *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error) {
return c.getVersion.CallUnary(ctx, req)
}
// VersionServiceHandler is an implementation of the health.v1.VersionService service.
type VersionServiceHandler interface {
// GetVersion returns the version of the daemon.
GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error)
}
// NewVersionServiceHandler builds an HTTP handler from the service implementation. It returns the
// path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewVersionServiceHandler(svc VersionServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
versionServiceMethods := v1.File_health_v1_api_proto.Services().ByName("VersionService").Methods()
versionServiceGetVersionHandler := connect.NewUnaryHandler(
VersionServiceGetVersionProcedure,
svc.GetVersion,
connect.WithSchema(versionServiceMethods.ByName("GetVersion")),
connect.WithHandlerOptions(opts...),
)
return "/health.v1.VersionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case VersionServiceGetVersionProcedure:
versionServiceGetVersionHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedVersionServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedVersionServiceHandler struct{}
func (UnimplementedVersionServiceHandler) GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("health.v1.VersionService.GetVersion is not implemented"))
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"errors"
"fmt"
)
var ErrEmptyName = errors.New("empty name")
type Name interface {
String() string
}
type name struct {
value string
}
func (n *name) String() string {
return n.value
}
// NewName creates a new [Name] from a string
// If a validation error occurs, it returns nil and the error.
func NewName(s string) (Name, error) {
if err := validName(s); err != nil {
return nil, fmt.Errorf("parsing name: %w", err)
}
return &name{
value: s,
}, nil
}
// MustNewName creates a new Name as [NewName] does, but panics when a validation
// error occurs.
func MustNewName(s string) Name {
v, err := NewName(s)
if err != nil {
panic(err)
}
return v
}
func validName(s string) error {
if s == "" {
return ErrEmptyName
}
return nil
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
edition = "2023";
package plugins.v1;
import "google/protobuf/duration.proto";
option go_package = "github.com/docker/secrets-engine/x/api/plugins/v1;pluginsv1";
service RegisterService {
// RegisterPlugin registers the plugin with the engine.
rpc RegisterPlugin(RegisterPluginRequest) returns (RegisterPluginResponse);
}
message RegisterPluginRequest {
// Name of the plugin to register.
string name = 1;
// Version of the plugin.
string version = 2;
// Type-specific fields
oneof metadata {
SecretsProvider secrets_provider = 3;
AccessControlModule access_control_module = 4;
}
}
message RegisterPluginResponse {
// Name of the secrets engine is running in.
string engine_name = 1;
// Version of the runtime engine is running in.
string engine_version = 2;
// Configured request processing timeout in milliseconds.
google.protobuf.Duration request_timeout = 3;
}
service PluginService {
// Shutdown a plugin (let it know the runtime is going down).
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
}
message ShutdownRequest {}
message ShutdownResponse {}
service PluginManagementService {
// ListPlugins returns all plugins registered with the engine.
rpc ListPlugins(ListPluginsRequest) returns (ListPluginsResponse);
// EnablePlugin enables a plugin by name.
rpc EnablePlugin(EnablePluginRequest) returns (EnablePluginResponse);
// DisablePlugin disables a plugin by name.
rpc DisablePlugin(DisablePluginRequest) returns (DisablePluginResponse);
}
enum RunStatus {
RUN_STATUS_UNSPECIFIED = 0;
RUN_STATUS_RUNNING = 1;
RUN_STATUS_STOPPED = 2;
RUN_STATUS_CRASHED = 3;
RUN_STATUS_RETRIES_EXCEEDED = 4;
}
message Plugin {
// Name
string name = 1;
// Version
string version = 2;
// Enabled vs disabled
bool disabled = 3;
// External vs builtin
bool external = 4;
// Configurable
bool configurable = 5;
// Run status
RunStatus run_status = 6;
// Human-readable detail for the current run_status. Populated for
// terminal/error statuses (CRASHED, RETRIES_EXCEEDED); typically empty
// otherwise. Not machine-parseable — intended for display only.
string status_message = 7;
// Type-specific fields
oneof metadata {
SecretsProvider secrets_provider = 8;
AccessControlModule access_control_module = 9;
}
}
message SecretsProvider {
// Pattern
string pattern = 1;
}
message AccessControlModule {}
message ListPluginsRequest {}
message ListPluginsResponse {
repeated Plugin plugins = 1;
}
message EnablePluginRequest {
string name = 1;
}
message EnablePluginResponse {}
message DisablePluginRequest {
string name = 1;
}
message DisablePluginResponse {}
@@ -0,0 +1,327 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: plugins/v1/api.proto
package pluginsv1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
v1 "github.com/docker/secrets-engine/x/api/plugins/v1"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// RegisterServiceName is the fully-qualified name of the RegisterService service.
RegisterServiceName = "plugins.v1.RegisterService"
// PluginServiceName is the fully-qualified name of the PluginService service.
PluginServiceName = "plugins.v1.PluginService"
// PluginManagementServiceName is the fully-qualified name of the PluginManagementService service.
PluginManagementServiceName = "plugins.v1.PluginManagementService"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// RegisterServiceRegisterPluginProcedure is the fully-qualified name of the RegisterService's
// RegisterPlugin RPC.
RegisterServiceRegisterPluginProcedure = "/plugins.v1.RegisterService/RegisterPlugin"
// PluginServiceShutdownProcedure is the fully-qualified name of the PluginService's Shutdown RPC.
PluginServiceShutdownProcedure = "/plugins.v1.PluginService/Shutdown"
// PluginManagementServiceListPluginsProcedure is the fully-qualified name of the
// PluginManagementService's ListPlugins RPC.
PluginManagementServiceListPluginsProcedure = "/plugins.v1.PluginManagementService/ListPlugins"
// PluginManagementServiceEnablePluginProcedure is the fully-qualified name of the
// PluginManagementService's EnablePlugin RPC.
PluginManagementServiceEnablePluginProcedure = "/plugins.v1.PluginManagementService/EnablePlugin"
// PluginManagementServiceDisablePluginProcedure is the fully-qualified name of the
// PluginManagementService's DisablePlugin RPC.
PluginManagementServiceDisablePluginProcedure = "/plugins.v1.PluginManagementService/DisablePlugin"
)
// RegisterServiceClient is a client for the plugins.v1.RegisterService service.
type RegisterServiceClient interface {
// RegisterPlugin registers the plugin with the engine.
RegisterPlugin(context.Context, *connect.Request[v1.RegisterPluginRequest]) (*connect.Response[v1.RegisterPluginResponse], error)
}
// NewRegisterServiceClient constructs a client for the plugins.v1.RegisterService service. By
// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses,
// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the
// connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewRegisterServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) RegisterServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
registerServiceMethods := v1.File_plugins_v1_api_proto.Services().ByName("RegisterService").Methods()
return &registerServiceClient{
registerPlugin: connect.NewClient[v1.RegisterPluginRequest, v1.RegisterPluginResponse](
httpClient,
baseURL+RegisterServiceRegisterPluginProcedure,
connect.WithSchema(registerServiceMethods.ByName("RegisterPlugin")),
connect.WithClientOptions(opts...),
),
}
}
// registerServiceClient implements RegisterServiceClient.
type registerServiceClient struct {
registerPlugin *connect.Client[v1.RegisterPluginRequest, v1.RegisterPluginResponse]
}
// RegisterPlugin calls plugins.v1.RegisterService.RegisterPlugin.
func (c *registerServiceClient) RegisterPlugin(ctx context.Context, req *connect.Request[v1.RegisterPluginRequest]) (*connect.Response[v1.RegisterPluginResponse], error) {
return c.registerPlugin.CallUnary(ctx, req)
}
// RegisterServiceHandler is an implementation of the plugins.v1.RegisterService service.
type RegisterServiceHandler interface {
// RegisterPlugin registers the plugin with the engine.
RegisterPlugin(context.Context, *connect.Request[v1.RegisterPluginRequest]) (*connect.Response[v1.RegisterPluginResponse], error)
}
// NewRegisterServiceHandler builds an HTTP handler from the service implementation. It returns the
// path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewRegisterServiceHandler(svc RegisterServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
registerServiceMethods := v1.File_plugins_v1_api_proto.Services().ByName("RegisterService").Methods()
registerServiceRegisterPluginHandler := connect.NewUnaryHandler(
RegisterServiceRegisterPluginProcedure,
svc.RegisterPlugin,
connect.WithSchema(registerServiceMethods.ByName("RegisterPlugin")),
connect.WithHandlerOptions(opts...),
)
return "/plugins.v1.RegisterService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case RegisterServiceRegisterPluginProcedure:
registerServiceRegisterPluginHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedRegisterServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedRegisterServiceHandler struct{}
func (UnimplementedRegisterServiceHandler) RegisterPlugin(context.Context, *connect.Request[v1.RegisterPluginRequest]) (*connect.Response[v1.RegisterPluginResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("plugins.v1.RegisterService.RegisterPlugin is not implemented"))
}
// PluginServiceClient is a client for the plugins.v1.PluginService service.
type PluginServiceClient interface {
// Shutdown a plugin (let it know the runtime is going down).
Shutdown(context.Context, *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error)
}
// NewPluginServiceClient constructs a client for the plugins.v1.PluginService service. By default,
// it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and
// sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC()
// or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewPluginServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PluginServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
pluginServiceMethods := v1.File_plugins_v1_api_proto.Services().ByName("PluginService").Methods()
return &pluginServiceClient{
shutdown: connect.NewClient[v1.ShutdownRequest, v1.ShutdownResponse](
httpClient,
baseURL+PluginServiceShutdownProcedure,
connect.WithSchema(pluginServiceMethods.ByName("Shutdown")),
connect.WithClientOptions(opts...),
),
}
}
// pluginServiceClient implements PluginServiceClient.
type pluginServiceClient struct {
shutdown *connect.Client[v1.ShutdownRequest, v1.ShutdownResponse]
}
// Shutdown calls plugins.v1.PluginService.Shutdown.
func (c *pluginServiceClient) Shutdown(ctx context.Context, req *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error) {
return c.shutdown.CallUnary(ctx, req)
}
// PluginServiceHandler is an implementation of the plugins.v1.PluginService service.
type PluginServiceHandler interface {
// Shutdown a plugin (let it know the runtime is going down).
Shutdown(context.Context, *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error)
}
// NewPluginServiceHandler builds an HTTP handler from the service implementation. It returns the
// path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewPluginServiceHandler(svc PluginServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
pluginServiceMethods := v1.File_plugins_v1_api_proto.Services().ByName("PluginService").Methods()
pluginServiceShutdownHandler := connect.NewUnaryHandler(
PluginServiceShutdownProcedure,
svc.Shutdown,
connect.WithSchema(pluginServiceMethods.ByName("Shutdown")),
connect.WithHandlerOptions(opts...),
)
return "/plugins.v1.PluginService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case PluginServiceShutdownProcedure:
pluginServiceShutdownHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedPluginServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedPluginServiceHandler struct{}
func (UnimplementedPluginServiceHandler) Shutdown(context.Context, *connect.Request[v1.ShutdownRequest]) (*connect.Response[v1.ShutdownResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("plugins.v1.PluginService.Shutdown is not implemented"))
}
// PluginManagementServiceClient is a client for the plugins.v1.PluginManagementService service.
type PluginManagementServiceClient interface {
// ListPlugins returns all plugins registered with the engine.
ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error)
// EnablePlugin enables a plugin by name.
EnablePlugin(context.Context, *connect.Request[v1.EnablePluginRequest]) (*connect.Response[v1.EnablePluginResponse], error)
// DisablePlugin disables a plugin by name.
DisablePlugin(context.Context, *connect.Request[v1.DisablePluginRequest]) (*connect.Response[v1.DisablePluginResponse], error)
}
// NewPluginManagementServiceClient constructs a client for the plugins.v1.PluginManagementService
// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for
// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply
// the connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewPluginManagementServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PluginManagementServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
pluginManagementServiceMethods := v1.File_plugins_v1_api_proto.Services().ByName("PluginManagementService").Methods()
return &pluginManagementServiceClient{
listPlugins: connect.NewClient[v1.ListPluginsRequest, v1.ListPluginsResponse](
httpClient,
baseURL+PluginManagementServiceListPluginsProcedure,
connect.WithSchema(pluginManagementServiceMethods.ByName("ListPlugins")),
connect.WithClientOptions(opts...),
),
enablePlugin: connect.NewClient[v1.EnablePluginRequest, v1.EnablePluginResponse](
httpClient,
baseURL+PluginManagementServiceEnablePluginProcedure,
connect.WithSchema(pluginManagementServiceMethods.ByName("EnablePlugin")),
connect.WithClientOptions(opts...),
),
disablePlugin: connect.NewClient[v1.DisablePluginRequest, v1.DisablePluginResponse](
httpClient,
baseURL+PluginManagementServiceDisablePluginProcedure,
connect.WithSchema(pluginManagementServiceMethods.ByName("DisablePlugin")),
connect.WithClientOptions(opts...),
),
}
}
// pluginManagementServiceClient implements PluginManagementServiceClient.
type pluginManagementServiceClient struct {
listPlugins *connect.Client[v1.ListPluginsRequest, v1.ListPluginsResponse]
enablePlugin *connect.Client[v1.EnablePluginRequest, v1.EnablePluginResponse]
disablePlugin *connect.Client[v1.DisablePluginRequest, v1.DisablePluginResponse]
}
// ListPlugins calls plugins.v1.PluginManagementService.ListPlugins.
func (c *pluginManagementServiceClient) ListPlugins(ctx context.Context, req *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error) {
return c.listPlugins.CallUnary(ctx, req)
}
// EnablePlugin calls plugins.v1.PluginManagementService.EnablePlugin.
func (c *pluginManagementServiceClient) EnablePlugin(ctx context.Context, req *connect.Request[v1.EnablePluginRequest]) (*connect.Response[v1.EnablePluginResponse], error) {
return c.enablePlugin.CallUnary(ctx, req)
}
// DisablePlugin calls plugins.v1.PluginManagementService.DisablePlugin.
func (c *pluginManagementServiceClient) DisablePlugin(ctx context.Context, req *connect.Request[v1.DisablePluginRequest]) (*connect.Response[v1.DisablePluginResponse], error) {
return c.disablePlugin.CallUnary(ctx, req)
}
// PluginManagementServiceHandler is an implementation of the plugins.v1.PluginManagementService
// service.
type PluginManagementServiceHandler interface {
// ListPlugins returns all plugins registered with the engine.
ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error)
// EnablePlugin enables a plugin by name.
EnablePlugin(context.Context, *connect.Request[v1.EnablePluginRequest]) (*connect.Response[v1.EnablePluginResponse], error)
// DisablePlugin disables a plugin by name.
DisablePlugin(context.Context, *connect.Request[v1.DisablePluginRequest]) (*connect.Response[v1.DisablePluginResponse], error)
}
// NewPluginManagementServiceHandler builds an HTTP handler from the service implementation. It
// returns the path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewPluginManagementServiceHandler(svc PluginManagementServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
pluginManagementServiceMethods := v1.File_plugins_v1_api_proto.Services().ByName("PluginManagementService").Methods()
pluginManagementServiceListPluginsHandler := connect.NewUnaryHandler(
PluginManagementServiceListPluginsProcedure,
svc.ListPlugins,
connect.WithSchema(pluginManagementServiceMethods.ByName("ListPlugins")),
connect.WithHandlerOptions(opts...),
)
pluginManagementServiceEnablePluginHandler := connect.NewUnaryHandler(
PluginManagementServiceEnablePluginProcedure,
svc.EnablePlugin,
connect.WithSchema(pluginManagementServiceMethods.ByName("EnablePlugin")),
connect.WithHandlerOptions(opts...),
)
pluginManagementServiceDisablePluginHandler := connect.NewUnaryHandler(
PluginManagementServiceDisablePluginProcedure,
svc.DisablePlugin,
connect.WithSchema(pluginManagementServiceMethods.ByName("DisablePlugin")),
connect.WithHandlerOptions(opts...),
)
return "/plugins.v1.PluginManagementService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case PluginManagementServiceListPluginsProcedure:
pluginManagementServiceListPluginsHandler.ServeHTTP(w, r)
case PluginManagementServiceEnablePluginProcedure:
pluginManagementServiceEnablePluginHandler.ServeHTTP(w, r)
case PluginManagementServiceDisablePluginProcedure:
pluginManagementServiceDisablePluginHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedPluginManagementServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedPluginManagementServiceHandler struct{}
func (UnimplementedPluginManagementServiceHandler) ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("plugins.v1.PluginManagementService.ListPlugins is not implemented"))
}
func (UnimplementedPluginManagementServiceHandler) EnablePlugin(context.Context, *connect.Request[v1.EnablePluginRequest]) (*connect.Response[v1.EnablePluginResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("plugins.v1.PluginManagementService.EnablePlugin is not implemented"))
}
func (UnimplementedPluginManagementServiceHandler) DisablePlugin(context.Context, *connect.Request[v1.DisablePluginRequest]) (*connect.Response[v1.DisablePluginResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("plugins.v1.PluginManagementService.DisablePlugin is not implemented"))
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package resolver
import (
"context"
"errors"
"fmt"
"time"
"connectrpc.com/connect"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
resolverv1 "github.com/docker/secrets-engine/x/api/resolver/v1"
"github.com/docker/secrets-engine/x/api/resolver/v1/resolverv1connect"
"github.com/docker/secrets-engine/x/secrets"
)
var _ resolverv1connect.ResolverServiceHandler = &resolverService{}
type resolverService struct {
resolver secrets.Resolver
}
func NewResolverHandler(r secrets.Resolver) resolverv1connect.ResolverServiceHandler {
return &resolverService{r}
}
func (r resolverService) GetSecrets(ctx context.Context, c *connect.Request[resolverv1.GetSecretsRequest]) (*connect.Response[resolverv1.GetSecretsResponse], error) {
msgPattern := c.Msg.GetPattern()
pattern, err := secrets.ParsePattern(msgPattern)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid pattern %q: %w", msgPattern, err))
}
envelopes, err := r.resolver.GetSecrets(ctx, pattern)
if err != nil {
if errors.Is(err, secrets.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, secrets.ErrNotFound)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get secret %q: %w", msgPattern, err))
}
if len(envelopes) == 0 {
return nil, connect.NewError(connect.CodeNotFound, secrets.ErrNotFound)
}
var items []*resolverv1.GetSecretsResponse_Envelope
for _, envelope := range envelopes {
items = append(items, resolverv1.GetSecretsResponse_Envelope_builder{
Id: proto.String(envelope.ID.String()),
Value: envelope.Value,
Metadata: envelope.Metadata,
Provider: proto.String(envelope.Provider),
Version: proto.String(envelope.Version),
CreatedAt: timestamppb.New(envelope.CreatedAt),
ResolvedAt: timestamppb.New(envelope.ResolvedAt),
ExpiresAt: timestamppb.New(envelope.ExpiresAt),
}.Build())
}
return connect.NewResponse(resolverv1.GetSecretsResponse_builder{
Envelopes: items,
}.Build()), nil
}
var _ secrets.Resolver = &resolverClient{}
type resolverClient struct {
resolverClient resolverv1connect.ResolverServiceClient
}
func NewResolverClient(httpClient connect.HTTPClient) secrets.Resolver {
return &resolverClient{resolverClient: resolverv1connect.NewResolverServiceClient(httpClient, "http://unix")}
}
func (r resolverClient) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) {
req := connect.NewRequest(resolverv1.GetSecretsRequest_builder{
Pattern: proto.String(pattern.String()),
}.Build())
resp, err := r.resolverClient.GetSecrets(ctx, req)
if err != nil {
if connect.CodeOf(err) == connect.CodeNotFound {
err = secrets.ErrNotFound
}
return nil, err
}
var envelopes []secrets.Envelope
for _, item := range resp.Msg.GetEnvelopes() {
id, err := secrets.ParseID(item.GetId())
if err != nil {
continue
}
envelopes = append(envelopes, secrets.Envelope{
ID: id,
Value: item.GetValue(),
Metadata: item.GetMetadata(),
Provider: item.GetProvider(),
Version: item.GetVersion(),
CreatedAt: item.GetCreatedAt().AsTime(),
ResolvedAt: item.GetResolvedAt().AsTime(),
ExpiresAt: item.GetExpiresAt().AsTime(),
})
}
return envelopes, nil
}
var _ resolverv1connect.AuthorizerServiceHandler = &authorizerService{}
type authorizerService struct {
authorizer secrets.Authorizer
}
// NewAuthorizerHandler adapts a secrets.Authorizer to the generated connect
// handler interface. It is served by the engine, not by secrets-provider
// plugins.
func NewAuthorizerHandler(a secrets.Authorizer) resolverv1connect.AuthorizerServiceHandler {
return &authorizerService{a}
}
func (s authorizerService) Authorize(ctx context.Context, c *connect.Request[resolverv1.AuthorizeRequest]) (*connect.Response[resolverv1.AuthorizeResponse], error) {
msgPatterns := c.Msg.GetPatterns()
if len(msgPatterns) == 0 {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one pattern is required"))
}
patterns := make([]secrets.Pattern, 0, len(msgPatterns))
for _, msgPattern := range msgPatterns {
pattern, err := secrets.ParsePattern(msgPattern)
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid pattern %q: %w", msgPattern, err))
}
patterns = append(patterns, pattern)
}
resp, err := s.authorizer.Authorize(ctx, patterns...)
if err != nil {
if errors.Is(err, secrets.ErrAccessDenied) {
return nil, connect.NewError(connect.CodePermissionDenied, secrets.ErrAccessDenied)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("authorize failed for %q: %w", msgPatterns, err))
}
decision := resolverv1.Decision_DECISION_DENY
if resp.Allow {
decision = resolverv1.Decision_DECISION_ALLOW
}
// A zero Expiry means the decision never expires: leave the timestamp absent.
var expiresAt *timestamppb.Timestamp
if !resp.Expiry.IsZero() {
expiresAt = timestamppb.New(resp.Expiry)
}
return connect.NewResponse(resolverv1.AuthorizeResponse_builder{
ExpiresAt: expiresAt,
Decision: &decision,
}.Build()), nil
}
var _ secrets.Authorizer = &authorizerClient{}
type authorizerClient struct {
client resolverv1connect.AuthorizerServiceClient
}
func NewAuthorizerClient(httpClient connect.HTTPClient) secrets.Authorizer {
return &authorizerClient{client: resolverv1connect.NewAuthorizerServiceClient(httpClient, "http://unix")}
}
func (a authorizerClient) Authorize(ctx context.Context, patterns ...secrets.Pattern) (secrets.AuthorizeResponse, error) {
msgPatterns := make([]string, 0, len(patterns))
for _, p := range patterns {
msgPatterns = append(msgPatterns, p.String())
}
resp, err := a.client.Authorize(ctx, connect.NewRequest(resolverv1.AuthorizeRequest_builder{
Patterns: msgPatterns,
}.Build()))
if err != nil {
if connect.CodeOf(err) == connect.CodePermissionDenied {
err = secrets.ErrAccessDenied
}
return secrets.AuthorizeResponse{}, err
}
// An absent timestamp means the decision never expires: keep the zero time.
var expiry time.Time
if resp.Msg.HasExpiresAt() {
expiry = resp.Msg.GetExpiresAt().AsTime()
}
return secrets.AuthorizeResponse{
Expiry: expiry,
Allow: resp.Msg.GetDecision() == resolverv1.Decision_DECISION_ALLOW,
}, nil
}
+728
View File
@@ -0,0 +1,728 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.7
// protoc (unknown)
// source: resolver/v1/api.proto
package resolverv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Decision int32
const (
// buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX
Decision_DECISION_DENY Decision = 0
Decision_DECISION_ALLOW Decision = 1
)
// Enum value maps for Decision.
var (
Decision_name = map[int32]string{
0: "DECISION_DENY",
1: "DECISION_ALLOW",
}
Decision_value = map[string]int32{
"DECISION_DENY": 0,
"DECISION_ALLOW": 1,
}
)
func (x Decision) Enum() *Decision {
p := new(Decision)
*p = x
return p
}
func (x Decision) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (Decision) Descriptor() protoreflect.EnumDescriptor {
return file_resolver_v1_api_proto_enumTypes[0].Descriptor()
}
func (Decision) Type() protoreflect.EnumType {
return &file_resolver_v1_api_proto_enumTypes[0]
}
func (x Decision) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
type GetSecretsRequest struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
xxx_hidden_Pattern *string `protobuf:"bytes,1,opt,name=pattern"`
XXX_raceDetectHookData protoimpl.RaceDetectHookData
XXX_presence [1]uint32
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSecretsRequest) Reset() {
*x = GetSecretsRequest{}
mi := &file_resolver_v1_api_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSecretsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSecretsRequest) ProtoMessage() {}
func (x *GetSecretsRequest) ProtoReflect() protoreflect.Message {
mi := &file_resolver_v1_api_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *GetSecretsRequest) GetPattern() string {
if x != nil {
if x.xxx_hidden_Pattern != nil {
return *x.xxx_hidden_Pattern
}
return ""
}
return ""
}
func (x *GetSecretsRequest) SetPattern(v string) {
x.xxx_hidden_Pattern = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 1)
}
func (x *GetSecretsRequest) HasPattern() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 0)
}
func (x *GetSecretsRequest) ClearPattern() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0)
x.xxx_hidden_Pattern = nil
}
type GetSecretsRequest_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
// ID of the secret to resolve.
Pattern *string
}
func (b0 GetSecretsRequest_builder) Build() *GetSecretsRequest {
m0 := &GetSecretsRequest{}
b, x := &b0, m0
_, _ = b, x
if b.Pattern != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 1)
x.xxx_hidden_Pattern = b.Pattern
}
return m0
}
type AuthorizeRequest struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
xxx_hidden_Patterns []string `protobuf:"bytes,1,rep,name=patterns"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthorizeRequest) Reset() {
*x = AuthorizeRequest{}
mi := &file_resolver_v1_api_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthorizeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthorizeRequest) ProtoMessage() {}
func (x *AuthorizeRequest) ProtoReflect() protoreflect.Message {
mi := &file_resolver_v1_api_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *AuthorizeRequest) GetPatterns() []string {
if x != nil {
return x.xxx_hidden_Patterns
}
return nil
}
func (x *AuthorizeRequest) SetPatterns(v []string) {
x.xxx_hidden_Patterns = v
}
type AuthorizeRequest_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
// Patterns to authorize; at least one required.
Patterns []string
}
func (b0 AuthorizeRequest_builder) Build() *AuthorizeRequest {
m0 := &AuthorizeRequest{}
b, x := &b0, m0
_, _ = b, x
x.xxx_hidden_Patterns = b.Patterns
return m0
}
type AuthorizeResponse struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
xxx_hidden_Decision Decision `protobuf:"varint,1,opt,name=decision,enum=resolver.v1.Decision"`
xxx_hidden_ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt"`
XXX_raceDetectHookData protoimpl.RaceDetectHookData
XXX_presence [1]uint32
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AuthorizeResponse) Reset() {
*x = AuthorizeResponse{}
mi := &file_resolver_v1_api_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthorizeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuthorizeResponse) ProtoMessage() {}
func (x *AuthorizeResponse) ProtoReflect() protoreflect.Message {
mi := &file_resolver_v1_api_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *AuthorizeResponse) GetDecision() Decision {
if x != nil {
if protoimpl.X.Present(&(x.XXX_presence[0]), 0) {
return x.xxx_hidden_Decision
}
}
return Decision_DECISION_DENY
}
func (x *AuthorizeResponse) GetExpiresAt() *timestamppb.Timestamp {
if x != nil {
return x.xxx_hidden_ExpiresAt
}
return nil
}
func (x *AuthorizeResponse) SetDecision(v Decision) {
x.xxx_hidden_Decision = v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 2)
}
func (x *AuthorizeResponse) SetExpiresAt(v *timestamppb.Timestamp) {
x.xxx_hidden_ExpiresAt = v
}
func (x *AuthorizeResponse) HasDecision() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 0)
}
func (x *AuthorizeResponse) HasExpiresAt() bool {
if x == nil {
return false
}
return x.xxx_hidden_ExpiresAt != nil
}
func (x *AuthorizeResponse) ClearDecision() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0)
x.xxx_hidden_Decision = Decision_DECISION_DENY
}
func (x *AuthorizeResponse) ClearExpiresAt() {
x.xxx_hidden_ExpiresAt = nil
}
type AuthorizeResponse_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
// The authorization decision.
Decision *Decision
// When the authorization decision expires.
ExpiresAt *timestamppb.Timestamp
}
func (b0 AuthorizeResponse_builder) Build() *AuthorizeResponse {
m0 := &AuthorizeResponse{}
b, x := &b0, m0
_, _ = b, x
if b.Decision != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 2)
x.xxx_hidden_Decision = *b.Decision
}
x.xxx_hidden_ExpiresAt = b.ExpiresAt
return m0
}
type GetSecretsResponse struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
xxx_hidden_Envelopes *[]*GetSecretsResponse_Envelope `protobuf:"bytes,1,rep,name=envelopes"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSecretsResponse) Reset() {
*x = GetSecretsResponse{}
mi := &file_resolver_v1_api_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSecretsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSecretsResponse) ProtoMessage() {}
func (x *GetSecretsResponse) ProtoReflect() protoreflect.Message {
mi := &file_resolver_v1_api_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *GetSecretsResponse) GetEnvelopes() []*GetSecretsResponse_Envelope {
if x != nil {
if x.xxx_hidden_Envelopes != nil {
return *x.xxx_hidden_Envelopes
}
}
return nil
}
func (x *GetSecretsResponse) SetEnvelopes(v []*GetSecretsResponse_Envelope) {
x.xxx_hidden_Envelopes = &v
}
type GetSecretsResponse_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
Envelopes []*GetSecretsResponse_Envelope
}
func (b0 GetSecretsResponse_builder) Build() *GetSecretsResponse {
m0 := &GetSecretsResponse{}
b, x := &b0, m0
_, _ = b, x
x.xxx_hidden_Envelopes = &b.Envelopes
return m0
}
type GetSecretsResponse_Envelope struct {
state protoimpl.MessageState `protogen:"opaque.v1"`
xxx_hidden_Id *string `protobuf:"bytes,1,opt,name=id"`
xxx_hidden_Value []byte `protobuf:"bytes,2,opt,name=value"`
xxx_hidden_Provider *string `protobuf:"bytes,3,opt,name=provider"`
xxx_hidden_Version *string `protobuf:"bytes,4,opt,name=version"`
xxx_hidden_CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt"`
xxx_hidden_ResolvedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=resolved_at,json=resolvedAt"`
xxx_hidden_ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expires_at,json=expiresAt"`
xxx_hidden_Metadata map[string]string `protobuf:"bytes,8,rep,name=metadata" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
XXX_raceDetectHookData protoimpl.RaceDetectHookData
XXX_presence [1]uint32
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSecretsResponse_Envelope) Reset() {
*x = GetSecretsResponse_Envelope{}
mi := &file_resolver_v1_api_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSecretsResponse_Envelope) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSecretsResponse_Envelope) ProtoMessage() {}
func (x *GetSecretsResponse_Envelope) ProtoReflect() protoreflect.Message {
mi := &file_resolver_v1_api_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *GetSecretsResponse_Envelope) GetId() string {
if x != nil {
if x.xxx_hidden_Id != nil {
return *x.xxx_hidden_Id
}
return ""
}
return ""
}
func (x *GetSecretsResponse_Envelope) GetValue() []byte {
if x != nil {
return x.xxx_hidden_Value
}
return nil
}
func (x *GetSecretsResponse_Envelope) GetProvider() string {
if x != nil {
if x.xxx_hidden_Provider != nil {
return *x.xxx_hidden_Provider
}
return ""
}
return ""
}
func (x *GetSecretsResponse_Envelope) GetVersion() string {
if x != nil {
if x.xxx_hidden_Version != nil {
return *x.xxx_hidden_Version
}
return ""
}
return ""
}
func (x *GetSecretsResponse_Envelope) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.xxx_hidden_CreatedAt
}
return nil
}
func (x *GetSecretsResponse_Envelope) GetResolvedAt() *timestamppb.Timestamp {
if x != nil {
return x.xxx_hidden_ResolvedAt
}
return nil
}
func (x *GetSecretsResponse_Envelope) GetExpiresAt() *timestamppb.Timestamp {
if x != nil {
return x.xxx_hidden_ExpiresAt
}
return nil
}
func (x *GetSecretsResponse_Envelope) GetMetadata() map[string]string {
if x != nil {
return x.xxx_hidden_Metadata
}
return nil
}
func (x *GetSecretsResponse_Envelope) SetId(v string) {
x.xxx_hidden_Id = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 8)
}
func (x *GetSecretsResponse_Envelope) SetValue(v []byte) {
if v == nil {
v = []byte{}
}
x.xxx_hidden_Value = v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 8)
}
func (x *GetSecretsResponse_Envelope) SetProvider(v string) {
x.xxx_hidden_Provider = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 2, 8)
}
func (x *GetSecretsResponse_Envelope) SetVersion(v string) {
x.xxx_hidden_Version = &v
protoimpl.X.SetPresent(&(x.XXX_presence[0]), 3, 8)
}
func (x *GetSecretsResponse_Envelope) SetCreatedAt(v *timestamppb.Timestamp) {
x.xxx_hidden_CreatedAt = v
}
func (x *GetSecretsResponse_Envelope) SetResolvedAt(v *timestamppb.Timestamp) {
x.xxx_hidden_ResolvedAt = v
}
func (x *GetSecretsResponse_Envelope) SetExpiresAt(v *timestamppb.Timestamp) {
x.xxx_hidden_ExpiresAt = v
}
func (x *GetSecretsResponse_Envelope) SetMetadata(v map[string]string) {
x.xxx_hidden_Metadata = v
}
func (x *GetSecretsResponse_Envelope) HasId() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 0)
}
func (x *GetSecretsResponse_Envelope) HasValue() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 1)
}
func (x *GetSecretsResponse_Envelope) HasProvider() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 2)
}
func (x *GetSecretsResponse_Envelope) HasVersion() bool {
if x == nil {
return false
}
return protoimpl.X.Present(&(x.XXX_presence[0]), 3)
}
func (x *GetSecretsResponse_Envelope) HasCreatedAt() bool {
if x == nil {
return false
}
return x.xxx_hidden_CreatedAt != nil
}
func (x *GetSecretsResponse_Envelope) HasResolvedAt() bool {
if x == nil {
return false
}
return x.xxx_hidden_ResolvedAt != nil
}
func (x *GetSecretsResponse_Envelope) HasExpiresAt() bool {
if x == nil {
return false
}
return x.xxx_hidden_ExpiresAt != nil
}
func (x *GetSecretsResponse_Envelope) ClearId() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0)
x.xxx_hidden_Id = nil
}
func (x *GetSecretsResponse_Envelope) ClearValue() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 1)
x.xxx_hidden_Value = nil
}
func (x *GetSecretsResponse_Envelope) ClearProvider() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 2)
x.xxx_hidden_Provider = nil
}
func (x *GetSecretsResponse_Envelope) ClearVersion() {
protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 3)
x.xxx_hidden_Version = nil
}
func (x *GetSecretsResponse_Envelope) ClearCreatedAt() {
x.xxx_hidden_CreatedAt = nil
}
func (x *GetSecretsResponse_Envelope) ClearResolvedAt() {
x.xxx_hidden_ResolvedAt = nil
}
func (x *GetSecretsResponse_Envelope) ClearExpiresAt() {
x.xxx_hidden_ExpiresAt = nil
}
type GetSecretsResponse_Envelope_builder struct {
_ [0]func() // Prevents comparability and use of unkeyed literals for the builder.
// ID of the secret to resolve.
Id *string
// The resolved secret value.
Value []byte
Provider *string
Version *string
CreatedAt *timestamppb.Timestamp
ResolvedAt *timestamppb.Timestamp
ExpiresAt *timestamppb.Timestamp
Metadata map[string]string
}
func (b0 GetSecretsResponse_Envelope_builder) Build() *GetSecretsResponse_Envelope {
m0 := &GetSecretsResponse_Envelope{}
b, x := &b0, m0
_, _ = b, x
if b.Id != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 8)
x.xxx_hidden_Id = b.Id
}
if b.Value != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 8)
x.xxx_hidden_Value = b.Value
}
if b.Provider != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 2, 8)
x.xxx_hidden_Provider = b.Provider
}
if b.Version != nil {
protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 3, 8)
x.xxx_hidden_Version = b.Version
}
x.xxx_hidden_CreatedAt = b.CreatedAt
x.xxx_hidden_ResolvedAt = b.ResolvedAt
x.xxx_hidden_ExpiresAt = b.ExpiresAt
x.xxx_hidden_Metadata = b.Metadata
return m0
}
var File_resolver_v1_api_proto protoreflect.FileDescriptor
const file_resolver_v1_api_proto_rawDesc = "" +
"\n" +
"\x15resolver/v1/api.proto\x12\vresolver.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"-\n" +
"\x11GetSecretsRequest\x12\x18\n" +
"\apattern\x18\x01 \x01(\tR\apattern\".\n" +
"\x10AuthorizeRequest\x12\x1a\n" +
"\bpatterns\x18\x01 \x03(\tR\bpatterns\"\x81\x01\n" +
"\x11AuthorizeResponse\x121\n" +
"\bdecision\x18\x01 \x01(\x0e2\x15.resolver.v1.DecisionR\bdecision\x129\n" +
"\n" +
"expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x89\x04\n" +
"\x12GetSecretsResponse\x12F\n" +
"\tenvelopes\x18\x01 \x03(\v2(.resolver.v1.GetSecretsResponse.EnvelopeR\tenvelopes\x1a\xaa\x03\n" +
"\bEnvelope\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05value\x18\x02 \x01(\fR\x05value\x12\x1a\n" +
"\bprovider\x18\x03 \x01(\tR\bprovider\x12\x18\n" +
"\aversion\x18\x04 \x01(\tR\aversion\x129\n" +
"\n" +
"created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12;\n" +
"\vresolved_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"resolvedAt\x129\n" +
"\n" +
"expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12R\n" +
"\bmetadata\x18\b \x03(\v26.resolver.v1.GetSecretsResponse.Envelope.MetadataEntryR\bmetadata\x1a;\n" +
"\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*1\n" +
"\bDecision\x12\x11\n" +
"\rDECISION_DENY\x10\x00\x12\x12\n" +
"\x0eDECISION_ALLOW\x10\x012`\n" +
"\x0fResolverService\x12M\n" +
"\n" +
"GetSecrets\x12\x1e.resolver.v1.GetSecretsRequest\x1a\x1f.resolver.v1.GetSecretsResponse2_\n" +
"\x11AuthorizerService\x12J\n" +
"\tAuthorize\x12\x1d.resolver.v1.AuthorizeRequest\x1a\x1e.resolver.v1.AuthorizeResponseB\xa7\x01\n" +
"\x0fcom.resolver.v1B\bApiProtoP\x01Z=github.com/docker/secrets-engine/x/api/resolver/v1;resolverv1\xa2\x02\x03RXX\xaa\x02\vResolver.V1\xca\x02\vResolver\\V1\xe2\x02\x17Resolver\\V1\\GPBMetadata\xea\x02\fResolver::V1b\beditionsp\xe8\a"
var file_resolver_v1_api_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_resolver_v1_api_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_resolver_v1_api_proto_goTypes = []any{
(Decision)(0), // 0: resolver.v1.Decision
(*GetSecretsRequest)(nil), // 1: resolver.v1.GetSecretsRequest
(*AuthorizeRequest)(nil), // 2: resolver.v1.AuthorizeRequest
(*AuthorizeResponse)(nil), // 3: resolver.v1.AuthorizeResponse
(*GetSecretsResponse)(nil), // 4: resolver.v1.GetSecretsResponse
(*GetSecretsResponse_Envelope)(nil), // 5: resolver.v1.GetSecretsResponse.Envelope
nil, // 6: resolver.v1.GetSecretsResponse.Envelope.MetadataEntry
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
}
var file_resolver_v1_api_proto_depIdxs = []int32{
0, // 0: resolver.v1.AuthorizeResponse.decision:type_name -> resolver.v1.Decision
7, // 1: resolver.v1.AuthorizeResponse.expires_at:type_name -> google.protobuf.Timestamp
5, // 2: resolver.v1.GetSecretsResponse.envelopes:type_name -> resolver.v1.GetSecretsResponse.Envelope
7, // 3: resolver.v1.GetSecretsResponse.Envelope.created_at:type_name -> google.protobuf.Timestamp
7, // 4: resolver.v1.GetSecretsResponse.Envelope.resolved_at:type_name -> google.protobuf.Timestamp
7, // 5: resolver.v1.GetSecretsResponse.Envelope.expires_at:type_name -> google.protobuf.Timestamp
6, // 6: resolver.v1.GetSecretsResponse.Envelope.metadata:type_name -> resolver.v1.GetSecretsResponse.Envelope.MetadataEntry
1, // 7: resolver.v1.ResolverService.GetSecrets:input_type -> resolver.v1.GetSecretsRequest
2, // 8: resolver.v1.AuthorizerService.Authorize:input_type -> resolver.v1.AuthorizeRequest
4, // 9: resolver.v1.ResolverService.GetSecrets:output_type -> resolver.v1.GetSecretsResponse
3, // 10: resolver.v1.AuthorizerService.Authorize:output_type -> resolver.v1.AuthorizeResponse
9, // [9:11] is the sub-list for method output_type
7, // [7:9] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_resolver_v1_api_proto_init() }
func file_resolver_v1_api_proto_init() {
if File_resolver_v1_api_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_resolver_v1_api_proto_rawDesc), len(file_resolver_v1_api_proto_rawDesc)),
NumEnums: 1,
NumMessages: 6,
NumExtensions: 0,
NumServices: 2,
},
GoTypes: file_resolver_v1_api_proto_goTypes,
DependencyIndexes: file_resolver_v1_api_proto_depIdxs,
EnumInfos: file_resolver_v1_api_proto_enumTypes,
MessageInfos: file_resolver_v1_api_proto_msgTypes,
}.Build()
File_resolver_v1_api_proto = out.File
file_resolver_v1_api_proto_goTypes = nil
file_resolver_v1_api_proto_depIdxs = nil
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
edition = "2023";
package resolver.v1;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/docker/secrets-engine/x/api/resolver/v1;resolverv1";
service ResolverService {
// Resolve a secret by its ID.
rpc GetSecrets(GetSecretsRequest) returns (GetSecretsResponse);
}
service AuthorizerService {
// Preflight: authorizes the caller for the patterns ahead of use, valid
// until the returned expiry.
rpc Authorize(AuthorizeRequest) returns (AuthorizeResponse);
}
message GetSecretsRequest {
// ID of the secret to resolve.
string pattern = 1;
}
message AuthorizeRequest {
// Patterns to authorize; at least one required.
repeated string patterns = 1;
}
enum Decision {
// buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX
DECISION_DENY = 0;
DECISION_ALLOW = 1;
}
message AuthorizeResponse {
// The authorization decision.
Decision decision = 1;
// When the authorization decision expires.
google.protobuf.Timestamp expires_at = 2;
}
message GetSecretsResponse {
repeated Envelope envelopes = 1;
message Envelope {
// ID of the secret to resolve.
string id = 1;
// The resolved secret value.
bytes value = 2;
string provider = 3;
string version = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp resolved_at = 6;
google.protobuf.Timestamp expires_at = 7;
map<string, string> metadata = 8;
}
}
@@ -0,0 +1,190 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: resolver/v1/api.proto
package resolverv1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
v1 "github.com/docker/secrets-engine/x/api/resolver/v1"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// ResolverServiceName is the fully-qualified name of the ResolverService service.
ResolverServiceName = "resolver.v1.ResolverService"
// AuthorizerServiceName is the fully-qualified name of the AuthorizerService service.
AuthorizerServiceName = "resolver.v1.AuthorizerService"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// ResolverServiceGetSecretsProcedure is the fully-qualified name of the ResolverService's
// GetSecrets RPC.
ResolverServiceGetSecretsProcedure = "/resolver.v1.ResolverService/GetSecrets"
// AuthorizerServiceAuthorizeProcedure is the fully-qualified name of the AuthorizerService's
// Authorize RPC.
AuthorizerServiceAuthorizeProcedure = "/resolver.v1.AuthorizerService/Authorize"
)
// ResolverServiceClient is a client for the resolver.v1.ResolverService service.
type ResolverServiceClient interface {
// Resolve a secret by its ID.
GetSecrets(context.Context, *connect.Request[v1.GetSecretsRequest]) (*connect.Response[v1.GetSecretsResponse], error)
}
// NewResolverServiceClient constructs a client for the resolver.v1.ResolverService service. By
// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses,
// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the
// connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewResolverServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ResolverServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
resolverServiceMethods := v1.File_resolver_v1_api_proto.Services().ByName("ResolverService").Methods()
return &resolverServiceClient{
getSecrets: connect.NewClient[v1.GetSecretsRequest, v1.GetSecretsResponse](
httpClient,
baseURL+ResolverServiceGetSecretsProcedure,
connect.WithSchema(resolverServiceMethods.ByName("GetSecrets")),
connect.WithClientOptions(opts...),
),
}
}
// resolverServiceClient implements ResolverServiceClient.
type resolverServiceClient struct {
getSecrets *connect.Client[v1.GetSecretsRequest, v1.GetSecretsResponse]
}
// GetSecrets calls resolver.v1.ResolverService.GetSecrets.
func (c *resolverServiceClient) GetSecrets(ctx context.Context, req *connect.Request[v1.GetSecretsRequest]) (*connect.Response[v1.GetSecretsResponse], error) {
return c.getSecrets.CallUnary(ctx, req)
}
// ResolverServiceHandler is an implementation of the resolver.v1.ResolverService service.
type ResolverServiceHandler interface {
// Resolve a secret by its ID.
GetSecrets(context.Context, *connect.Request[v1.GetSecretsRequest]) (*connect.Response[v1.GetSecretsResponse], error)
}
// NewResolverServiceHandler builds an HTTP handler from the service implementation. It returns the
// path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewResolverServiceHandler(svc ResolverServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
resolverServiceMethods := v1.File_resolver_v1_api_proto.Services().ByName("ResolverService").Methods()
resolverServiceGetSecretsHandler := connect.NewUnaryHandler(
ResolverServiceGetSecretsProcedure,
svc.GetSecrets,
connect.WithSchema(resolverServiceMethods.ByName("GetSecrets")),
connect.WithHandlerOptions(opts...),
)
return "/resolver.v1.ResolverService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case ResolverServiceGetSecretsProcedure:
resolverServiceGetSecretsHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedResolverServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedResolverServiceHandler struct{}
func (UnimplementedResolverServiceHandler) GetSecrets(context.Context, *connect.Request[v1.GetSecretsRequest]) (*connect.Response[v1.GetSecretsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("resolver.v1.ResolverService.GetSecrets is not implemented"))
}
// AuthorizerServiceClient is a client for the resolver.v1.AuthorizerService service.
type AuthorizerServiceClient interface {
// Preflight: authorizes the caller for the patterns ahead of use, valid
// until the returned expiry.
Authorize(context.Context, *connect.Request[v1.AuthorizeRequest]) (*connect.Response[v1.AuthorizeResponse], error)
}
// NewAuthorizerServiceClient constructs a client for the resolver.v1.AuthorizerService service. By
// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses,
// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the
// connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewAuthorizerServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AuthorizerServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
authorizerServiceMethods := v1.File_resolver_v1_api_proto.Services().ByName("AuthorizerService").Methods()
return &authorizerServiceClient{
authorize: connect.NewClient[v1.AuthorizeRequest, v1.AuthorizeResponse](
httpClient,
baseURL+AuthorizerServiceAuthorizeProcedure,
connect.WithSchema(authorizerServiceMethods.ByName("Authorize")),
connect.WithClientOptions(opts...),
),
}
}
// authorizerServiceClient implements AuthorizerServiceClient.
type authorizerServiceClient struct {
authorize *connect.Client[v1.AuthorizeRequest, v1.AuthorizeResponse]
}
// Authorize calls resolver.v1.AuthorizerService.Authorize.
func (c *authorizerServiceClient) Authorize(ctx context.Context, req *connect.Request[v1.AuthorizeRequest]) (*connect.Response[v1.AuthorizeResponse], error) {
return c.authorize.CallUnary(ctx, req)
}
// AuthorizerServiceHandler is an implementation of the resolver.v1.AuthorizerService service.
type AuthorizerServiceHandler interface {
// Preflight: authorizes the caller for the patterns ahead of use, valid
// until the returned expiry.
Authorize(context.Context, *connect.Request[v1.AuthorizeRequest]) (*connect.Response[v1.AuthorizeResponse], error)
}
// NewAuthorizerServiceHandler builds an HTTP handler from the service implementation. It returns
// the path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewAuthorizerServiceHandler(svc AuthorizerServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
authorizerServiceMethods := v1.File_resolver_v1_api_proto.Services().ByName("AuthorizerService").Methods()
authorizerServiceAuthorizeHandler := connect.NewUnaryHandler(
AuthorizerServiceAuthorizeProcedure,
svc.Authorize,
connect.WithSchema(authorizerServiceMethods.ByName("Authorize")),
connect.WithHandlerOptions(opts...),
)
return "/resolver.v1.AuthorizerService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case AuthorizerServiceAuthorizeProcedure:
authorizerServiceAuthorizeHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedAuthorizerServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedAuthorizerServiceHandler struct{}
func (UnimplementedAuthorizerServiceHandler) Authorize(context.Context, *connect.Request[v1.AuthorizeRequest]) (*connect.Response[v1.AuthorizeResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("resolver.v1.AuthorizerService.Authorize is not implemented"))
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"errors"
"fmt"
"golang.org/x/mod/semver"
)
var (
ErrEmptyVersion = errors.New("empty version")
ErrVPrefix = errors.New("missing version prefix")
)
type Version interface {
String() string
}
type version struct {
value string
}
func (v *version) String() string {
return v.value
}
// NewVersion creates a new [Version] from a string
// If a validation error occurs, it returns nil and the error.
func NewVersion(s string) (Version, error) {
if err := valid(s); err != nil {
return nil, fmt.Errorf("parsing version: %w", err)
}
return &version{
value: s,
}, nil
}
// MustNewVersion creates a new Version as [NewVersion] does, but panics when a validation
// error occurs.
func MustNewVersion(s string) Version {
v, err := NewVersion(s)
if err != nil {
panic(err)
}
return v
}
// Compare returns an integer comparing two versions according to semantic versioning.
// The result will be 0 if a == b, -1 if a < b, or +1 if a > b.
// An invalid semver string is considered less than a valid one.
func Compare(a, b Version) int {
return semver.Compare(a.String(), b.String())
}
func valid(s string) error {
if len(s) > 0 && s[0] != 'v' {
return ErrVPrefix
}
if s == "" {
return ErrEmptyVersion
}
if !semver.IsValid(s) {
return fmt.Errorf("invalid version: %s", s)
}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package realms
import "github.com/docker/secrets-engine/x/secrets"
// Docker realms all start with `docker/` as the prefix.
//
// Authentication flows done by the Docker CLI, Docker Desktop and Docker related
// products must go through `docker/auth`.
//
// Docker Hub authentication (browser based OAuth login) will be prefixed
// with `docker/auth/hub/<username>`.
//
// Docker Registry authentication will be prefixed with
// `docker/auth/registry/docker/<username>`.
var (
DockerHubAuthentication = secrets.MustParsePattern("docker/auth/hub/**")
DockerHubStagingAuthentication = secrets.MustParsePattern("docker/auth/hub-staging/**")
DockerRegistryAuthentication = secrets.MustParsePattern("docker/auth/registry/docker/**")
DockerRegistryStagingAuthentication = secrets.MustParsePattern("docker/auth/registry/docker-staging/**")
)
var (
// DockerHubAuthenticationMetadata holds one profile entry per signed-in
// Docker Hub account, plus a default entry duplicating the default
// account's profile.
DockerHubAuthenticationMetadata = secrets.MustParsePattern("docker/auth/metadata/hub/**")
// DockerHubStagingAuthenticationMetadata is the staging variant of
// [DockerHubAuthenticationMetadata].
DockerHubStagingAuthenticationMetadata = secrets.MustParsePattern("docker/auth/metadata/hub-staging/**")
)
var (
// DockerMCPDefault is the default realm used for MCP related secrets
DockerMCPDefault = secrets.MustParsePattern("docker/mcp/**")
// DockerMCPOAuth is the realm used for all MCP OAuth credentials retrieved by Docker.
DockerMCPOAuth = secrets.MustParsePattern("docker/mcp/oauth/**")
// DockerMCPOAuthDCR is the realm used to hold Dynamic Client Registered (DCR)
// OAuth configurations for supported MCP servers.
DockerMCPOAuthDCR = secrets.MustParsePattern("docker/mcp/oauth-dcr/**")
)
var (
// DockerSandbox is the default realm used for Sandbox related secrets
DockerSandbox = secrets.MustParsePattern("docker/sandbox/**")
// DockerSandboxOAuth is the realm used for all Sandbox OAuth credentials
// such as third-party tokens - this does not store the Docker Auth tokens.
DockerSandboxOAuth = secrets.MustParsePattern("docker/sandbox/oauth/**")
)
var (
// SecretsEngine is the default realm used for secrets-engine related secrets.
SecretsEngine = secrets.MustParsePattern("docker/secrets-engine/**")
// SecretsEnginePlugins is the realm used to store credentials and configuration for secrets-engine plugins.
SecretsEnginePlugins = secrets.MustParsePattern("docker/secrets-engine/plugins/**")
)
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package realms keeps track of known Docker realms
//
// Realms do not define a permission model and should not be used as such!
// Realms are simply contracts that clients may use to query a set of secrets.
// Once a realm has been established it MUST not change as clients would treat
// the realm as a contract.
// Clients may pin themselves to a specific realm or a group of realms.
//
// Examples:
//
// `docker/` is a realm for all known Docker secrets.
// `docker/auth` is a realm for all known Docker Auth secrets.
package realms
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package secrets
import (
"fmt"
"strings"
)
type ErrInvalidID struct {
ID string
}
func (e ErrInvalidID) Error() string {
return fmt.Sprintf("invalid identifier: %q must match ^[A-Za-z0-9._:-]+(?:/[A-Za-z0-9._:-]+)*$", e.ID)
}
// validIdentifier checks if an identifier is valid without using regexp or unicode.
// Rules:
// - Components separated by '/'
// - Each component is non-empty
// - Only characters A-Z, a-z, 0-9, '.', '_', '-' or ':'
// - No leading, trailing, or double slashes
func validIdentifier(s string) bool {
if len(s) == 0 {
return false
}
componentLen := 0
for _, r := range s {
switch {
case r == '/':
if componentLen == 0 {
// Empty component (leading, trailing, or double slash)
return false
}
componentLen = 0
case isValidRune(r):
componentLen++
default:
return false
}
}
// Final component must not be empty
return componentLen > 0
}
func isValidRune(c rune) bool {
return (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '.' || c == '-' || c == '_' || c == ':'
}
func split(s string) []string {
s = strings.Trim(s, "/")
if s == "" {
return []string{}
}
return strings.Split(s, "/")
}
func match(pattern, path []string) bool {
pi, si := 0, 0
for pi < len(pattern) && si < len(path) {
switch pattern[pi] {
case "**":
// Try to consume any number of path segments
if pi+1 == len(pattern) {
return true // trailing ** matches rest
}
// Try to find a match for the rest of the pattern
for skip := 0; si+skip <= len(path); skip++ {
if match(pattern[pi+1:], path[si+skip:]) {
return true
}
}
return false
case "*":
// Match exactly one path component
pi++
si++
default:
if pattern[pi] != path[si] {
return false
}
pi++
si++
}
}
// Handle trailing pattern parts (like **)
for pi < len(pattern) && pattern[pi] == "**" {
pi++
}
return pi == len(pattern) && si == len(path)
}
func valid(id string) error {
if !validIdentifier(id) {
return ErrInvalidID{
ID: id,
}
}
return nil
}
// ID contains a secret identifier.
// Valid secret identifiers must match the format ^[A-Za-z0-9._:-]+(?:/[A-Za-z0-9._:-]+)*$.
//
// For storage, we don't really differentiate much about the ID format but
// by convention we do simple, slash-separated management, providing a
// groupable access control system for management across plugins.
type ID interface {
// String formats the [ID] as a string
String() string
// Match the [ID] against a [Pattern]
// It checks if a given identifier matches the pattern.
// - "*" matches a single component
// - "**" matches zero or more components
// - "/" is the separator
Match(pattern Pattern) bool
}
type id string
func (i id) Match(pattern Pattern) bool {
pathParts := split(string(i))
patternParts := split(pattern.String())
return match(patternParts, pathParts)
}
func (i id) String() string {
return string(i)
}
// ParseID creates a new [ID] from a string
// If a validation error occurs, it returns nil and the error.
// Rules:
// - Components separated by '/'
// - Each component is non-empty
// - Only characters A-Z, a-z, 0-9, '.', '_', '-' or ':'
// - No leading, trailing, or double slashes
func ParseID(s string) (ID, error) {
if err := valid(s); err != nil {
return nil, err
}
return id(s), nil
}
// MustParseID parses a string into a [ID] and behaves similar to
// [ParseID], however, it panics when the id is invalid
func MustParseID(s string) ID {
if err := valid(s); err != nil {
panic(err)
}
return id(s)
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package secrets
import "strings"
func canonicalKey(toks []token) string {
var sb strings.Builder
for i, t := range toks {
if i > 0 {
sb.WriteByte('/')
}
switch t.kind {
case tokenStar:
sb.WriteByte('*')
case tokenGap:
sb.WriteString("**")
case tokenLit:
sb.WriteString(t.lit)
}
}
return sb.String()
}
// Minimize removes each pattern that another contains (see
// [Pattern.Contains]); when patterns match the same IDs, only the first
// stays. The result preserves input order.
//
// Examples:
//
// [** a/*] -> [**]
// [**/* */** **] -> [**/*]
// [a/** **/a] -> [a/** **/a]
//
// Complexity: O(n²·L²) for n patterns of at most L components each.
func Minimize(patterns []Pattern) []Pattern {
entries := make([]entry, 0, len(patterns))
seen := make(map[string]struct{}, len(patterns))
for _, p := range patterns {
toks := canonicalize(p.String())
key := canonicalKey(toks)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
entries = append(entries, newEntry(p, toks))
}
result := make([]Pattern, 0, len(entries))
for i := range entries {
redundant := false
for j := range entries {
if i == j || !subsumes(&entries[j], &entries[i]) {
continue
}
if j < i || !covers(entries[i].toks, entries[j].toks) {
redundant = true
break
}
}
if !redundant {
result = append(result, entries[i].original)
}
}
return result
}
type entry struct {
original Pattern
toks []token
minLen int
hasWild bool
hasGaps bool
firstLit string
lastLit string
}
func newEntry(p Pattern, toks []token) entry {
e := entry{original: p, toks: toks}
for _, t := range toks {
if t.kind != tokenLit {
e.hasWild = true
}
if t.kind == tokenGap {
e.hasGaps = true
} else {
e.minLen++
}
}
if len(toks) > 0 {
e.firstLit = toks[0].lit
e.lastLit = toks[len(toks)-1].lit
}
return e
}
// subsumes reports whether other matches every ID e matches. It compares
// the precomputed fields first, so most pairs skip the O(L²) covers call.
func subsumes(other, e *entry) bool {
if !other.hasWild {
return false
}
if other.minLen > e.minLen {
return false
}
if !other.hasGaps && (e.hasGaps || other.minLen != e.minLen) {
return false
}
if other.firstLit != "" && other.firstLit != e.firstLit {
return false
}
if other.lastLit != "" && other.lastLit != e.lastLit {
return false
}
return covers(other.toks, e.toks)
}
+295
View File
@@ -0,0 +1,295 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package secrets
import (
"errors"
"fmt"
"strings"
)
var ErrInvalidPattern = errors.New("invalid pattern")
// validPattern reports whether s is a valid pattern: non-empty '/'-separated
// components of A-Z, a-z, 0-9, '.', '-', '_', ':', where a component may
// instead be '*' or '**'.
func validPattern(s string) bool {
if len(s) == 0 {
return false
}
componentLen := 0
wildcardLen := 0
for _, r := range s {
switch {
case r == '/':
if !isValidComponentMatcher(componentLen, wildcardLen) {
return false
}
componentLen = 0
wildcardLen = 0
case isValidPatternRune(r):
componentLen++
if r == '*' {
wildcardLen++
}
default:
return false
}
}
// Final component
return isValidComponentMatcher(componentLen, wildcardLen)
}
func isValidComponentMatcher(componentLen, wildcardLen int) bool {
if wildcardLen > 2 {
// No more than two wildcards per component
return false
}
if wildcardLen > 0 && wildcardLen != componentLen {
// Wildcard can't be mixed with other characters in the same component
return false
}
// Component must not be empty
return componentLen > 0
}
func isValidPatternRune(c rune) bool {
return isValidRune(c) || c == '*'
}
// Pattern matches secret IDs. It follows the ID validation rules, except
// that '*' matches one component and '**' matches zero or more.
// Below, matches(p) denotes the set of IDs a pattern p matches.
type Pattern interface {
// Match reports whether the pattern matches id.
// Complexity: O(n*m^k), where id has n components and the pattern has
// m components and k occurrences of '**'.
Match(id ID) bool
// Contains reports whether every ID that [other] matches can also be
// matched by the pattern: the set of IDs [other] matches is contained
// in the set the pattern matches, i.e., matches(other) ⊆ matches(p).
//
// Examples:
// - docker/** contains docker/*/mcp/*: '**' is more general than
// '*/mcp/*'.
// - docker/proj1/** does not contain docker/*/mcp/*: docker/*/mcp/*
// matches docker/proj2/mcp/x, but docker/proj1/** does not.
//
// Complexity: O(n*m)
Contains(other Pattern) bool
// Overlaps reports whether the pattern and [other] match at least one
// ID in common, i.e., matches(p) ∩ matches(other) ≠ ∅.
//
// Examples:
// - docker/*/mcp/* and docker/proj1/** overlap: both match e.g. docker/proj1/mcp/x.
// - bar/** and foo/** do not overlap: an ID cannot begin with both bar and foo.
//
// Complexity: O(n*m)
Overlaps(other Pattern) bool
// String returns the pattern text.
String() string
ExpandID(other ID) (ID, error)
ExpandPattern(other Pattern) (Pattern, error)
}
type pattern string
func (p pattern) Match(id ID) bool {
pathParts := split(id.String())
patternParts := split(string(p))
return match(patternParts, pathParts)
}
func (p pattern) Contains(other Pattern) bool {
return covers(canonicalize(string(p)), canonicalize(other.String()))
}
func (p pattern) Overlaps(other Pattern) bool {
return compatible(canonicalize(string(p)), canonicalize(other.String()))
}
type tokenKind uint8
const (
tokenLit tokenKind = iota
tokenStar
tokenGap
)
type token struct {
kind tokenKind
lit string
}
// canonicalize tokenizes a pattern so that two patterns match the same set
// of IDs exactly when their canonical tokens are equal. Two rewrites give
// that property:
//
// - In a run of consecutive wildcard components, the '*' move to the
// front and the '**' merge into one at the end: order within a run
// does not affect what it matches, and one '**' already matches any
// number of components.
// - A pattern consisting only of '**' becomes "*/**": an ID always has
// at least one component, so both match the same IDs, and the first
// rewrite already turns the equivalent spelling "**/*" into "*/**".
//
// Examples:
//
// a/a/** and a/a/**/** -> [a a **]
// a/**/*/**/b -> [a * ** b]
// ** and **/* -> [* **]
func canonicalize(s string) []token {
parts := split(s)
toks := make([]token, 0, len(parts))
stars, gap := 0, false
flush := func() {
for range stars {
toks = append(toks, token{kind: tokenStar})
}
if gap {
toks = append(toks, token{kind: tokenGap})
}
stars, gap = 0, false
}
for _, part := range parts {
switch part {
case "*":
stars++
case "**":
gap = true
default:
flush()
toks = append(toks, token{kind: tokenLit, lit: part})
}
}
flush()
if len(toks) == 1 && toks[0].kind == tokenGap {
toks = []token{{kind: tokenStar}, {kind: tokenGap}}
}
return toks
}
// covers reports whether p matches every ID q matches, for canonical tokens.
// The component alphabet is unbounded, so a literal in p never covers an '*'
// or '**' in q.
func covers(p, q []token) bool {
np, nq := len(p), len(q)
prev := make([]bool, nq+1)
cur := make([]bool, nq+1)
prev[nq] = true
for i := np - 1; i >= 0; i-- {
cur[nq] = p[i].kind == tokenGap && prev[nq]
for j := nq - 1; j >= 0; j-- {
switch {
case p[i].kind == tokenGap:
cur[j] = prev[j] || cur[j+1]
case q[j].kind == tokenGap:
cur[j] = p[i].kind == tokenStar && cur[j+1] && prev[j]
case p[i].kind == tokenStar:
cur[j] = prev[j+1]
default:
cur[j] = q[j].kind == tokenLit && p[i].lit == q[j].lit && prev[j+1]
}
}
prev, cur = cur, prev
}
return prev[0]
}
// compatible reports whether p and q match at least one ID in common, for
// canonical tokens. A common ID aligns both token lists over its
// components: wildcards accept anything, so only differing literals or
// mismatched lengths (* vs */foo) rule one out.
func compatible(p, q []token) bool {
np, nq := len(p), len(q)
prev := make([]bool, nq+1)
cur := make([]bool, nq+1)
prev[nq] = true
for j := nq - 1; j >= 0; j-- {
prev[j] = q[j].kind == tokenGap && prev[j+1]
}
for i := np - 1; i >= 0; i-- {
cur[nq] = p[i].kind == tokenGap && prev[nq]
for j := nq - 1; j >= 0; j-- {
switch {
case p[i].kind == tokenGap || q[j].kind == tokenGap:
cur[j] = prev[j] || cur[j+1]
case p[i].kind == tokenLit && q[j].kind == tokenLit && p[i].lit != q[j].lit:
cur[j] = false
default:
cur[j] = prev[j+1]
}
}
prev, cur = cur, prev
}
return prev[0]
}
func (p pattern) String() string {
return string(p)
}
// ParsePattern parses s into a [Pattern]: non-empty '/'-separated components
// of A-Z, a-z, 0-9, '.', '-', '_', ':', where a component may instead be '*'
// or '**'. It returns [ErrInvalidPattern] for anything else.
func ParsePattern(s string) (Pattern, error) {
if !validPattern(s) {
return nil, ErrInvalidPattern
}
return pattern(s), nil
}
// MustParsePattern is like [ParsePattern] but panics on an invalid pattern.
func MustParsePattern(s string) Pattern {
if !validPattern(s) {
panic(ErrInvalidPattern)
}
return pattern(s)
}
func (p pattern) ExpandID(other ID) (ID, error) {
val, err := replace1(string(p), other.String())
if err != nil {
return nil, err
}
return ParseID(val)
}
func (p pattern) ExpandPattern(other Pattern) (Pattern, error) {
val, err := replace1(string(p), other.String())
if err != nil {
return nil, err
}
return pattern(val), err
}
func replace1(original, other string) (string, error) {
components := split(original)
var candidates []int
for idx, val := range components {
if val == "**" {
candidates = append(candidates, idx)
}
}
if len(candidates) != 1 {
return "", fmt.Errorf("expand only supports one expansion glob, pattern %s has %d", original, len(candidates))
}
components[candidates[0]] = other
return strings.Join(components, "/"), nil
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2025-2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package secrets
import (
"context"
"encoding/json"
"errors"
"time"
)
var (
ErrNotFound = errors.New("secret not found")
ErrAccessDenied = errors.New("access denied") // nuh, uh, uh!
ErrEmptySecret = errors.New("secret value must not be empty")
)
type Envelope struct {
ID ID `json:"-"`
Value []byte `json:"-"`
Metadata map[string]string `json:"-"`
Provider string `json:"-"`
Version string `json:"-"`
CreatedAt time.Time `json:"-"`
ResolvedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
var _ json.Marshaler = Envelope{}
func (e Envelope) MarshalJSON() ([]byte, error) {
panic("secrets.Envelope does not support json.Marshal")
}
type Resolver interface {
GetSecrets(ctx context.Context, pattern Pattern) ([]Envelope, error)
}
type AuthorizeResponse struct {
// Expiry is when the decision stops holding. A zero Expiry means the
// decision never expires, and the wire message then carries no timestamp.
Expiry time.Time
Allow bool
}
type Authorizer interface {
Authorize(ctx context.Context, pattern ...Pattern) (AuthorizeResponse, error)
}
+22 -1
View File
@@ -1,3 +1,7 @@
# connectrpc.com/connect v1.19.1
## explicit; go 1.24.0
connectrpc.com/connect
connectrpc.com/connect/internal/gen/connectext/grpc/status/v1
# dario.cat/mergo v1.0.2
## explicit; go 1.13
dario.cat/mergo
@@ -87,10 +91,27 @@ github.com/docker/go-metrics
github.com/docker/go-units
# github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7
## explicit
# github.com/docker/secrets-engine/client v0.2.2
## explicit; go 1.26.10
github.com/docker/secrets-engine/client
github.com/docker/secrets-engine/client/dockerhub
github.com/docker/secrets-engine/client/realms
# github.com/docker/secrets-engine/x v0.8.5
## explicit; go 1.26.10
github.com/docker/secrets-engine/x/api
github.com/docker/secrets-engine/x/api/health/v1
github.com/docker/secrets-engine/x/api/health/v1/healthv1connect
github.com/docker/secrets-engine/x/api/plugins/v1
github.com/docker/secrets-engine/x/api/plugins/v1/pluginsv1connect
github.com/docker/secrets-engine/x/api/resolver
github.com/docker/secrets-engine/x/api/resolver/v1
github.com/docker/secrets-engine/x/api/resolver/v1/resolverv1connect
github.com/docker/secrets-engine/x/realms
github.com/docker/secrets-engine/x/secrets
# github.com/felixge/httpsnoop v1.1.0
## explicit; go 1.25
github.com/felixge/httpsnoop
# github.com/fvbommel/sortorder v1.2.0
# github.com/fvbommel/sortorder v1.2.1
## explicit; go 1.21
github.com/fvbommel/sortorder
# github.com/go-jose/go-jose/v4 v4.1.5