Moved IMDS emulator to a separate repository.

This commit is contained in:
slawek committed 2026-10-20 11:11:52 +02:00
1 parent 89c41a200d
commit 6d8cc12eda
9 files changed
+16 -1343

No files matched your search

+15 -14
View File
@@ -101,7 +101,7 @@ The containers run with the following settings:
| Container | Setting | Value | | Container | Setting | Value |
|---|---|---| |---|---|---|
| `imds-emulator` | Image | built from `sts-agent/Dockerfile`: a static Go binary on `gcr.io/distroless/static-debian13` | | `imds-emulator` | Image | `gce-imds-emulator:latest`, built from the repository `skoszewski/gce-imds-emulator`, set by `IMDS_EMULATOR_IMAGE` |
| | Port | `8080`, set by `IMDS_PORT` | | | Port | `8080`, set by `IMDS_PORT` |
| | Bind address | all interfaces, set by `IMDS_HOST` | | | Bind address | all interfaces, set by `IMDS_HOST` |
| | Mount | `/etc/sts-agent`, read only | | | Mount | `/etc/sts-agent`, read only |
@@ -132,21 +132,31 @@ STS_AGENT_POOL=my-agents
HOSTNAME=agent-host HOSTNAME=agent-host
``` ```
The following optional variables of `sts-agent/.env` set the health check of the emulator: The following optional variables of `sts-agent/.env` set the image and the health check of the emulator:
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `IMDS_EMULATOR_IMAGE` | `gce-imds-emulator:latest` | Image of the emulator |
| `HEALTHCHECK_INTERVAL` | `10s` | Time between checks | | `HEALTHCHECK_INTERVAL` | `10s` | Time between checks |
| `HEALTHCHECK_TIMEOUT` | `3s` | Time after which a check fails | | `HEALTHCHECK_TIMEOUT` | `3s` | Time after which a check fails |
| `HEALTHCHECK_START_PERIOD` | `10s` | Time after the start in which failed checks do not count | | `HEALTHCHECK_START_PERIOD` | `10s` | Time after the start in which failed checks do not count |
| `HEALTHCHECK_START_INTERVAL` | `1s` | Time between checks during the start period | | `HEALTHCHECK_START_INTERVAL` | `1s` | Time between checks during the start period |
| `HEALTHCHECK_RETRIES` | `3` | Consecutive failed checks that make the container unhealthy | | `HEALTHCHECK_RETRIES` | `3` | Consecutive failed checks that make the container unhealthy |
Build the emulator and start both containers: Build the emulator image on the agent host:
```bash
git clone https://github.com/skoszewski/gce-imds-emulator.git
cd gce-imds-emulator
docker build --pull -t gce-imds-emulator:latest .
```
The `README.md` of the emulator repository describes other ways to provide the image, through an archive or a registry. With a registry, set `IMDS_EMULATOR_IMAGE` in `sts-agent/.env` to the registry path of the image.
Start both containers from the directory of this repository:
```bash ```bash
cd sts-agent cd sts-agent
docker compose build
docker compose up -d docker compose up -d
``` ```
@@ -164,16 +174,7 @@ docker compose logs imds-emulator
Requests for paths that the emulator does not serve are logged with the status 404. Requests for paths that the emulator does not serve are logged with the status 404.
The emulator is a static binary without dependencies on files of the system: it embeds the root certificates and the time zone data, and it is the first process (PID 1) of its container. On `SIGTERM` or `SIGINT`, which `docker compose stop` sends, it stops accepting connections, lets the requests in progress finish for up to 20 seconds, and exits with status 0. A second signal makes it exit with status 1 at once. The stop grace period of the container is 30 seconds, longer than the shutdown of the emulator. On `SIGTERM` or `SIGINT`, which `docker compose stop` sends, the emulator lets the requests in progress finish for up to 20 seconds. The stop grace period of the container is 30 seconds, longer than the shutdown of the emulator. The `README.md` of the emulator repository describes its endpoints, its implementation and its tests.
The emulator keeps at most 16 token providers, one per set of scopes requested, and drops them all when the limit is reached.
The unit tests of the emulator, `sts-agent/main_test.go`, cover the request checks, the metadata endpoints, the scope parsing, the token response, the request log, the extraction of the service account email from the credentials file, the shutdown, the check mode, and runs of the emulator as a process stopped with real signals. They run during the build of the image, so a failing test stops it. To run them on a workstation with Go:
```bash
cd sts-agent
go test -race ./...
```
The source directory is mounted at the same path as on the host, because the transfer job refers to it by that path. The source directory is mounted at the same path as on the host, because the transfer job refers to it by that path.
-5
View File
@@ -1,5 +0,0 @@
*
!go.mod
!go.sum
!main.go
!main_test.go
-15
View File
@@ -1,15 +0,0 @@
FROM golang:1.27.3-trixie AS build
ENV GOTOOLCHAIN=local CGO_ENABLED=0
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY main.go main_test.go ./
RUN go test ./... \
&& go build -trimpath -ldflags="-s -w" -o /imds-emulator .
FROM gcr.io/distroless/static-debian13:latest
COPY --from=build /imds-emulator /imds-emulator
ENTRYPOINT ["/imds-emulator"]
-128
View File
@@ -1,128 +0,0 @@
# IMDS Emulator
The IMDS emulator is a token proxy. It serves the endpoints of the Compute Engine metadata server (IMDS) and answers token requests with access tokens it obtains through its own Application Default Credentials (ADC).
A client that runs outside Compute Engine and supports only the metadata server, or cannot use the credential type at hand, gets the tokens of a service account without a credentials file of its own. The emulator holds the credentials. The client only needs the address of the emulator in `GCE_METADATA_HOST` and `GCE_METADATA_IP`, which the Google client libraries use instead of the address of the metadata server.
Any credential type supported by ADC works, including Workload Identity Federation (WIF) with an X.509 client certificate and service account impersonation.
## Configuration
The emulator reads its settings from the environment:
| Variable | Default | Meaning |
|---|---|---|
| `GOOGLE_APPLICATION_CREDENTIALS` | ADC search order | Credentials file used to obtain the tokens |
| `GOOGLE_API_CERTIFICATE_CONFIG` | none | Certificate configuration of X.509 credentials |
| `SERVICE_ACCOUNT_EMAIL` | taken from the credentials | Email of the service account reported to clients |
| `PROJECT_ID` | project of the credentials | Value of `project/project-id` |
| `PROJECT_NUMBER` | none | Value of `project/numeric-project-id` |
| `IMDS_HOSTNAME` | host name of the container | Value of `instance/hostname` |
| `IMDS_HOST` | all interfaces | Address the server binds |
| `IMDS_PORT` | `8080` | Port the server listens on |
The service account email is taken from the impersonation URL of the credentials file, or from `client_email` of a service account key. An external account without impersonation has no email in the file, so it needs `SERVICE_ACCOUNT_EMAIL`. The emulator does not start without an email or without valid ADC.
The option `-check` turns the binary into a health check. It sends the metadata ping to `IMDS_HOST`, or to `127.0.0.1` when the variable is empty, on `IMDS_PORT`, and exits with status 0 when the emulator answers within 2 seconds.
## Endpoints
All paths are below `/computeMetadata/v1/`. The service account is named `default` or by its email.
| Path | Response |
|---|---|
| `/` and `/computeMetadata/v1/` | Empty ping response |
| `instance/service-accounts/` | Account list; JSON with `?recursive=true` |
| `instance/service-accounts/default/` | Account attributes; JSON with `?recursive=true` |
| `instance/service-accounts/default/token` | Access token as JSON with `access_token`, `expires_in` and `token_type`; the scopes come from the comma separated `scopes` query parameter, `cloud-platform` without it |
| `instance/service-accounts/default/email` | Service account email |
| `instance/service-accounts/default/scopes` | `cloud-platform` scope |
| `project/project-id` | Project ID, 404 when unknown |
| `project/numeric-project-id` | Project number, 404 when unknown |
| `instance/hostname` | Host name |
| `universe/universe-domain` | `googleapis.com` |
Every other path returns 404. Methods other than `GET` and `HEAD` return 405. Requests without the header `Metadata-Flavor: Google`, or with `X-Forwarded-For`, return 403. Every response carries `Metadata-Flavor: Google`.
The emulator logs one line per request with the method, the path and the status. It never logs the query string or the token.
## Implementation
The emulator is a single Go program, `main.go`, with its unit tests in `main_test.go`. It uses the standard library for the HTTP server and two modules besides it:
- `cloud.google.com/go/auth` finds the ADC and obtains, caches and refreshes the tokens.
- `golang.org/x/crypto/x509roots/fallback` embeds fallback root certificates, and the standard package `time/tzdata` embeds fallback time zone data.
The program consists of the following parts:
- **`metadataServer`:** the HTTP handler. It checks the request, maps the email alias to `default`, and answers the endpoints. It gets tokens through a function field and the time through a clock field, so the tests replace both.
- **`providerCache`:** keeps one token provider per set of scopes. The scopes are sorted, so their order in the query does not matter. Detection errors are not cached, so a later request retries.
- **`serviceAccountEmail`:** extracts the email from the credentials file.
- **`serve`:** runs the server until its context is canceled, then shuts it down within a grace period.
- **`main`:** reads the environment, detects the ADC, handles the signals, and starts the server or runs the check.
The token response reports the remaining lifetime of the token in `expires_in`. A token without expiry is reported with 3600 seconds.
### Embedded root certificates and time zone data
The embedded root certificates are the Mozilla NSS trust store, the root store of Firefox, as of the version of `golang.org/x/crypto/x509roots/fallback` in `go.sum`. Go uses them only when it finds no root certificates on the system. On Linux it reads the first existing file of a fixed list, starting with `/etc/ssl/certs/ca-certificates.crt`, and the files in `/etc/ssl/certs` and `/etc/pki/tls/certs`. The variables `SSL_CERT_FILE` and `SSL_CERT_DIR` replace the file list and the directory list. When neither yields a certificate, Go uses the embedded roots. `GODEBUG=x509usefallbackroots=1` makes Go use the embedded roots even when the system has its own.
The roots verify the TLS connections of the emulator to the Google token endpoints, which the authentication library makes without its own root pool.
The embedded time zone data is the zone database of the Go release that built the binary. Go reads a zone from the files of the system first, from `/usr/share/zoneinfo/` and the other system zone directories, and uses the embedded data only for a zone it does not find there. The emulator uses a zone only for the timestamps of its log, which are in local time. The local time zone comes from the variable `TZ`, and from `/etc/localtime` without it. When neither names a zone that loads, the timestamps are in UTC. The embedded data therefore matters only when `TZ` names a zone that the system has no file for.
The image `gcr.io/distroless/static-debian13` contains the root certificate bundle of Debian and the zone files, so in the container the emulator uses those and not the embedded data. The embedded data takes effect when the binary runs on a system without them, such as a `scratch` image.
### Image
The image is built in two stages by the `Dockerfile`. The first stage runs the unit tests and builds a static binary with `CGO_ENABLED=0`. The second stage copies the binary to `gcr.io/distroless/static-debian13`, which runs it as its entry point.
## Architectural decisions
- **Go with a static binary.** The binary has no runtime dependencies, so it runs on a distroless image without a shell, a package manager or an interpreter. The embedded root certificates and time zone data are fallbacks, so the binary also works on a system without these files, while the files of the system take precedence where they exist.
- **ADC as the only credential source.** The emulator delegates the credential handling to the Google authentication library and supports every credential type the library does. It reads the X.509 certificate and key through the certificate configuration, so certificates renewed in place are used without a restart of the emulator.
- **Token caching in the library.** The emulator stores no tokens of its own. Each token provider caches its token and refreshes it before expiry, so repeated client requests do not reach the token service.
- **Bounded provider cache.** At most 16 providers are kept. When the limit is reached, all of them are dropped. A client therefore cannot grow the memory of the emulator by requesting many scope sets, and the simple eviction suffices because clients use few scope sets.
- **Subset of the metadata server.** The emulator serves the endpoints that the Google client libraries use for authentication and project detection. Other paths return 404 instead of made up values, so a client that depends on them fails visibly.
- **Request checks of the metadata server.** The emulator rejects requests without the flavor header and proxied requests, as the metadata server does. This protects against requests forged by a browser or forwarded by a proxy, but it does not authenticate the client.
- **Network as the trust boundary.** Every client that reaches the port gets the tokens of the service account. The emulator therefore publishes no port in a container setup, and binds the loopback interface through `IMDS_HOST` when it runs on a host.
- **No error details to clients.** A failed token request returns a generic 500. The details go to the log, where only the operator sees them.
- **Signal handling as PID 1.** The emulator runs as the first process of its container, which receives no default signal handling from the kernel. It handles `SIGTERM` and `SIGINT` itself: the first signal stops accepting connections and lets the requests in progress finish for up to 20 seconds, then the emulator exits with status 0. A second signal makes it exit with status 1 at once. The stop grace period of the container must exceed 20 seconds.
- **Health check in the same binary.** The distroless image has no shell or HTTP client, so the binary checks itself with `-check`. The check uses the same ping as the clients.
- **Tests in the image build.** The unit tests run in the build stage, so a failing test stops the build of the image.
- **Unprivileged port.** The default port 8080 does not require privileges, and clients include it in `GCE_METADATA_HOST`.
## Build and test
Build the image:
```bash
docker build -t imds-emulator .
```
Run the emulator on its own network with the credentials directory mounted, replacing `/etc/imds-emulator` with the directory of the credentials files:
```bash
docker network create imds
docker run -d --name imds-emulator --network imds \
-v /etc/imds-emulator:/etc/imds-emulator:ro \
-e GOOGLE_APPLICATION_CREDENTIALS=/etc/imds-emulator/cred.json \
-e GOOGLE_API_CERTIFICATE_CONFIG=/etc/imds-emulator/certificate_config.json \
imds-emulator
```
Request a token from a container on the same network:
```bash
docker run --rm --network imds curlimages/curl \
-s -H "Metadata-Flavor: Google" \
http://imds-emulator:8080/computeMetadata/v1/instance/service-accounts/default/token
```
Run the unit tests on a workstation with Go:
```bash
go test -race ./...
```
The tests cover the request checks, the endpoints, the scope parsing, the token response, the request log, the provider cache, the extraction of the email, the shutdown, the health check, and runs of the emulator as a process stopped with real signals.
+1 -1
View File
@@ -1,7 +1,7 @@
name: sts name: sts
services: services:
imds-emulator: imds-emulator:
build: . image: ${IMDS_EMULATOR_IMAGE:-gce-imds-emulator:latest}
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s stop_grace_period: 30s
volumes: volumes:
-15
View File
@@ -1,15 +0,0 @@
module imds-emulator
go 1.27.3
require (
cloud.google.com/go/auth v0.24.1
golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.26.2 // indirect
golang.org/x/sys v0.47.0 // indirect
)
-49
View File
@@ -1,49 +0,0 @@
cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs=
cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8=
github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559 h1:M68k3yCPiXhrSyh9Twe3Kv7nOsvUumZDN/3kdSwUqw0=
golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/api v0.288.0 h1:glhO/J88obKP5I269W3hB73dvBKrjU56ZfmNlNXpgTU=
google.golang.org/api v0.288.0/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d h1:Jkpk39hlTZOIp3RbfvNX9R8Hv+Sw0X89nlU/xFOErsc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
-341
View File
@@ -1,341 +0,0 @@
// Command imds-emulator serves the access tokens of the Application Default
// Credentials on the Compute Engine metadata server (IMDS) endpoints.
package main
import (
"cmp"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"regexp"
"slices"
"strings"
"sync"
"syscall"
"time"
_ "time/tzdata" // Time zone data used when the system has none.
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/credentials"
_ "golang.org/x/crypto/x509roots/fallback" // Root certificates used when the system has none.
)
const (
defaultScope = "https://www.googleapis.com/auth/cloud-platform"
root = "/computeMetadata/v1/"
accounts = "instance/service-accounts/"
defaultAccount = accounts + "default/"
// shutdownGrace is shorter than the stop grace period of the container.
shutdownGrace = 20 * time.Second
// maxProviders bounds the token providers kept for distinct scope sets.
maxProviders = 16
)
// tokenFunc returns an access token for the scopes.
type tokenFunc func(ctx context.Context, scopes []string) (*auth.Token, error)
// serviceAccount is the metadata of a service account.
type serviceAccount struct {
Aliases []string `json:"aliases"`
Email string `json:"email"`
Scopes []string `json:"scopes"`
}
// metadataServer answers the IMDS requests of the Google client libraries.
type metadataServer struct {
email string
projectID string
projectNumber string
hostname string
token tokenFunc
now func() time.Time
}
// ServeHTTP answers a request and logs its method, path and status.
func (s *metadataServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Metadata-Flavor", "Google")
w.Header().Set("Server", "Metadata Server for VM")
status := s.handle(w, r)
log.Printf("%s %s %d", r.Method, r.URL.Path, status)
}
// handle writes the response of a request and returns its status.
func (s *metadataServer) handle(w http.ResponseWriter, r *http.Request) int {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return text(w, http.StatusMethodNotAllowed, "Method Not Allowed")
}
// The real IMDS rejects requests without the flavor header and proxied requests.
if r.Header.Get("Metadata-Flavor") != "Google" || r.Header.Get("X-Forwarded-For") != "" {
return text(w, http.StatusForbidden, "Forbidden")
}
if r.URL.Path == "/" || r.URL.Path == root {
return text(w, http.StatusOK, "")
}
path, found := strings.CutPrefix(r.URL.Path, root)
if !found {
return text(w, http.StatusNotFound, "Not Found")
}
// Both "default" and the email address name the service account.
for _, alias := range []string{"default", s.email} {
if rest, found := strings.CutPrefix(path, accounts+alias+"/"); found {
path = defaultAccount + rest
break
}
}
query := r.URL.Query()
recursive := query.Get("recursive") == "true"
account := serviceAccount{Aliases: []string{"default"}, Email: s.email, Scopes: []string{defaultScope}}
switch {
case path == defaultAccount+"token":
token, err := s.token(r.Context(), parseScopes(query.Get("scopes")))
if err != nil {
log.Printf("token error: %v", err)
return text(w, http.StatusInternalServerError, "Internal Server Error")
}
expiresIn := 3600
if !token.Expiry.IsZero() {
expiresIn = max(int(token.Expiry.Sub(s.now()).Seconds()), 0)
}
return jsonBody(w, map[string]any{
"access_token": token.Value,
"expires_in": expiresIn,
"token_type": cmp.Or(token.Type, "Bearer"),
})
case path == defaultAccount+"email":
return text(w, http.StatusOK, s.email)
case path == defaultAccount+"scopes":
return text(w, http.StatusOK, defaultScope+"\n")
case path == defaultAccount && recursive:
return jsonBody(w, account)
case path == defaultAccount:
return text(w, http.StatusOK, "aliases\nemail\nscopes\ntoken\n")
case path == accounts && recursive:
return jsonBody(w, map[string]serviceAccount{"default": account, s.email: account})
case path == accounts:
return text(w, http.StatusOK, "default/\n"+s.email+"/\n")
case path == "project/project-id" && s.projectID != "":
return text(w, http.StatusOK, s.projectID)
case path == "project/numeric-project-id" && s.projectNumber != "":
return text(w, http.StatusOK, s.projectNumber)
case path == "instance/hostname":
return text(w, http.StatusOK, s.hostname)
case path == "universe/universe-domain":
return text(w, http.StatusOK, "googleapis.com")
}
return text(w, http.StatusNotFound, "Not Found")
}
// parseScopes returns the comma separated scopes of the query, or the default scope.
func parseScopes(value string) []string {
scopes := slices.DeleteFunc(strings.Split(value, ","), func(scope string) bool { return scope == "" })
if len(scopes) == 0 {
return []string{defaultScope}
}
return scopes
}
// text writes a plain text response and returns its status.
func text(w http.ResponseWriter, status int, body string) int {
w.Header().Set("Content-Type", "application/text")
w.WriteHeader(status)
if _, err := io.WriteString(w, body); err != nil {
log.Printf("write error: %v", err)
}
return status
}
// jsonBody writes a JSON response with the status 200 and returns it.
func jsonBody(w http.ResponseWriter, value any) int {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("write error: %v", err)
}
return http.StatusOK
}
// ping checks that the emulator at the address answers the metadata ping.
func ping(address string) error {
request, err := http.NewRequest(http.MethodGet, "http://"+address+"/", nil)
if err != nil {
return err
}
request.Header.Set("Metadata-Flavor", "Google")
client := &http.Client{Timeout: 2 * time.Second}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || response.Header.Get("Metadata-Flavor") != "Google" {
return fmt.Errorf("unexpected response: %s", response.Status)
}
return nil
}
var impersonationURL = regexp.MustCompile(`/serviceAccounts/([^/:]+):generateAccessToken$`)
// serviceAccountEmail returns the service account of a credentials file, or an empty string.
func serviceAccountEmail(credentialsJSON []byte) string {
var file struct {
ClientEmail string `json:"client_email"`
ImpersonationURL string `json:"service_account_impersonation_url"`
}
if json.Unmarshal(credentialsJSON, &file) != nil {
return ""
}
if match := impersonationURL.FindStringSubmatch(file.ImpersonationURL); match != nil {
return match[1]
}
return file.ClientEmail
}
// providerCache keeps one token provider per set of scopes.
type providerCache struct {
mu sync.Mutex
detect func(scopes []string) (auth.TokenProvider, error)
providers map[string]auth.TokenProvider
}
// get returns the token provider of the scopes, creating it on first use.
func (c *providerCache) get(scopes []string) (auth.TokenProvider, error) {
c.mu.Lock()
defer c.mu.Unlock()
key := strings.Join(scopes, ",")
if provider, found := c.providers[key]; found {
return provider, nil
}
provider, err := c.detect(scopes)
if err != nil {
return nil, err
}
if c.providers == nil {
c.providers = map[string]auth.TokenProvider{}
}
// Drop all providers once the limit is reached, so the cache never grows unbounded.
if len(c.providers) >= maxProviders {
clear(c.providers)
}
c.providers[key] = provider
return provider, nil
}
// token returns a token for the scopes, independent of their order.
func (c *providerCache) token(ctx context.Context, scopes []string) (*auth.Token, error) {
sorted := slices.Sorted(slices.Values(scopes))
provider, err := c.get(sorted)
if err != nil {
return nil, err
}
return provider.Token(ctx)
}
// serve serves the listener until the context is canceled, then stops the
// server, waiting up to the grace period for the requests in progress.
func serve(ctx context.Context, server *http.Server, listener net.Listener, grace time.Duration) error {
failed := make(chan error, 1)
go func() { failed <- server.Serve(listener) }()
select {
case err := <-failed:
return err
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
server.Close()
return err
}
<-failed
return nil
}
func main() {
check := flag.Bool("check", false, "check that the emulator answers and exit")
flag.Parse()
// IMDS_HOST empty binds all interfaces; the check then uses the loopback address.
host, port := os.Getenv("IMDS_HOST"), cmp.Or(os.Getenv("IMDS_PORT"), "8080")
if *check {
if err := ping(net.JoinHostPort(cmp.Or(host, "127.0.0.1"), port)); err != nil {
log.Fatalf("Check failed: %v", err)
}
return
}
// The first signal stops the server gracefully, the second one exits at once.
signals := make(chan os.Signal, 2)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
log.Printf("Received %v, shutting down", <-signals)
cancel()
log.Printf("Received %v again, exiting", <-signals)
os.Exit(1)
}()
detect := func(scopes []string) (auth.TokenProvider, error) {
creds, err := credentials.DetectDefault(&credentials.DetectOptions{Scopes: scopes})
if err != nil {
return nil, err
}
return creds, nil
}
defaults, err := credentials.DetectDefault(&credentials.DetectOptions{Scopes: []string{defaultScope}})
if err != nil {
log.Fatalf("Cannot find the Application Default Credentials: %v", err)
}
email := cmp.Or(os.Getenv("SERVICE_ACCOUNT_EMAIL"), serviceAccountEmail(defaults.JSON()))
if email == "" {
log.Fatal("The service account email is unknown, set SERVICE_ACCOUNT_EMAIL")
}
projectID := os.Getenv("PROJECT_ID")
if projectID == "" {
projectID, _ = defaults.ProjectID(ctx)
}
hostname, _ := os.Hostname()
server := &metadataServer{
email: email,
projectID: projectID,
projectNumber: os.Getenv("PROJECT_NUMBER"),
hostname: cmp.Or(os.Getenv("IMDS_HOSTNAME"), hostname),
token: (&providerCache{detect: detect}).token,
now: time.Now,
}
httpServer := &http.Server{
Handler: server,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
listener, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err != nil {
log.Fatalf("Cannot listen: %v", err)
}
log.Printf("Serving tokens of %s for project %s on %s", email, cmp.Or(projectID, "-"), listener.Addr())
if err := serve(ctx, httpServer, listener, shutdownGrace); err != nil {
log.Fatalf("Server stopped: %v", err)
}
log.Print("Stopped")
}
-775
View File
@@ -1,775 +0,0 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"cloud.google.com/go/auth"
)
const (
testEmail = "sa-test@dom-lab.iam.gserviceaccount.com"
mainEnv = "IMDS_EMULATOR_RUN_MAIN"
)
var testNow = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
func TestMain(m *testing.M) {
// The signal tests run the test binary as the emulator.
if os.Getenv(mainEnv) == "1" {
main()
return
}
log.SetOutput(io.Discard)
os.Exit(m.Run())
}
// tokenProviderFunc adapts a function to auth.TokenProvider.
type tokenProviderFunc func(ctx context.Context) (*auth.Token, error)
func (f tokenProviderFunc) Token(ctx context.Context) (*auth.Token, error) { return f(ctx) }
// newTestServer returns a server whose tokens are named after the scopes and valid for an hour.
func newTestServer(scopesSeen *[]string) *metadataServer {
return &metadataServer{
email: testEmail,
projectID: "dom-lab",
projectNumber: "267098479356",
hostname: "agent-host",
now: func() time.Time { return testNow },
token: func(ctx context.Context, scopes []string) (*auth.Token, error) {
if scopesSeen != nil {
*scopesSeen = scopes
}
return &auth.Token{Value: "token-for-" + strings.Join(scopes, ","), Type: "Bearer", Expiry: testNow.Add(time.Hour)}, nil
},
}
}
// get sends a request with the metadata flavor header to the server.
func get(server *metadataServer, target string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, target, nil)
request.Header.Set("Metadata-Flavor", "Google")
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, request)
return recorder
}
func TestRequestChecks(t *testing.T) {
tests := []struct {
name string
method string
headers map[string]string
want int
}{
{"valid", http.MethodGet, map[string]string{"Metadata-Flavor": "Google"}, http.StatusOK},
{"head", http.MethodHead, map[string]string{"Metadata-Flavor": "Google"}, http.StatusOK},
{"missing flavor", http.MethodGet, nil, http.StatusForbidden},
{"wrong flavor", http.MethodGet, map[string]string{"Metadata-Flavor": "Other"}, http.StatusForbidden},
{"forwarded", http.MethodGet, map[string]string{"Metadata-Flavor": "Google", "X-Forwarded-For": "192.0.2.1"}, http.StatusForbidden},
{"post", http.MethodPost, map[string]string{"Metadata-Flavor": "Google"}, http.StatusMethodNotAllowed},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(test.method, root+"project/project-id", nil)
for name, value := range test.headers {
request.Header.Set(name, value)
}
recorder := httptest.NewRecorder()
newTestServer(nil).ServeHTTP(recorder, request)
if recorder.Code != test.want {
t.Errorf("status = %d, want %d", recorder.Code, test.want)
}
if got := recorder.Header().Get("Metadata-Flavor"); got != "Google" {
t.Errorf("response Metadata-Flavor = %q, want Google", got)
}
})
}
}
func TestEndpoints(t *testing.T) {
tests := []struct {
name string
target string
status int
body string
}{
{"ping", "/", 200, ""},
{"root", root, 200, ""},
{"project ID", root + "project/project-id", 200, "dom-lab"},
{"project number", root + "project/numeric-project-id", 200, "267098479356"},
{"hostname", root + "instance/hostname", 200, "agent-host"},
{"universe domain", root + "universe/universe-domain", 200, "googleapis.com"},
{"email", root + "instance/service-accounts/default/email", 200, testEmail},
{"email by address", root + "instance/service-accounts/" + testEmail + "/email", 200, testEmail},
{"scopes", root + "instance/service-accounts/default/scopes", 200, defaultScope + "\n"},
{"account list", root + "instance/service-accounts/", 200, "default/\n" + testEmail + "/\n"},
{"account contents", root + "instance/service-accounts/default/", 200, "aliases\nemail\nscopes\ntoken\n"},
{"other account", root + "instance/service-accounts/other@dom-lab.iam.gserviceaccount.com/email", 404, "Not Found"},
{"unknown path", root + "instance/zone", 404, "Not Found"},
{"outside root", "/other", 404, "Not Found"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := get(newTestServer(nil), test.target)
if recorder.Code != test.status || recorder.Body.String() != test.body {
t.Errorf("got %d %q, want %d %q", recorder.Code, recorder.Body.String(), test.status, test.body)
}
})
}
}
func TestMissingProject(t *testing.T) {
server := newTestServer(nil)
server.projectID, server.projectNumber = "", ""
for _, name := range []string{"project/project-id", "project/numeric-project-id"} {
if recorder := get(server, root+name); recorder.Code != http.StatusNotFound {
t.Errorf("%s status = %d, want 404", name, recorder.Code)
}
}
}
func TestAccountInfo(t *testing.T) {
want := serviceAccount{Aliases: []string{"default"}, Email: testEmail, Scopes: []string{defaultScope}}
recorder := get(newTestServer(nil), root+"instance/service-accounts/default/?recursive=true")
var account serviceAccount
if err := json.Unmarshal(recorder.Body.Bytes(), &account); err != nil {
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
}
if recorder.Code != 200 || !slices.Equal(account.Aliases, want.Aliases) || account.Email != want.Email || !slices.Equal(account.Scopes, want.Scopes) {
t.Errorf("got %d %+v, want 200 %+v", recorder.Code, account, want)
}
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
recorder = get(newTestServer(nil), root+"instance/service-accounts/?recursive=true")
var listed map[string]serviceAccount
if err := json.Unmarshal(recorder.Body.Bytes(), &listed); err != nil {
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
}
if len(listed) != 2 || listed["default"].Email != testEmail || listed[testEmail].Email != testEmail {
t.Errorf("got %+v, want default and %s", listed, testEmail)
}
}
func TestToken(t *testing.T) {
tests := []struct {
name string
target string
wantScopes []string
wantToken string
}{
{"default scope", root + "instance/service-accounts/default/token", []string{defaultScope}, "token-for-" + defaultScope},
{"scopes", root + "instance/service-accounts/default/token?scopes=a,b", []string{"a", "b"}, "token-for-a,b"},
{"by address", root + "instance/service-accounts/" + testEmail + "/token", []string{defaultScope}, "token-for-" + defaultScope},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var scopes []string
recorder := get(newTestServer(&scopes), test.target)
var response struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
}
if recorder.Code != 200 || response.AccessToken != test.wantToken || response.ExpiresIn != 3600 || response.TokenType != "Bearer" {
t.Errorf("got %d %+v", recorder.Code, response)
}
if !slices.Equal(scopes, test.wantScopes) {
t.Errorf("scopes = %v, want %v", scopes, test.wantScopes)
}
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
})
}
}
func TestParseScopes(t *testing.T) {
tests := []struct {
value string
want []string
}{
{"", []string{defaultScope}},
{",,", []string{defaultScope}},
{"a", []string{"a"}},
{"a,,b,", []string{"a", "b"}},
}
for _, test := range tests {
if got := parseScopes(test.value); !slices.Equal(got, test.want) {
t.Errorf("parseScopes(%q) = %v, want %v", test.value, got, test.want)
}
}
}
func TestRequestLogOmitsQuery(t *testing.T) {
var logged bytes.Buffer
log.SetOutput(&logged)
defer log.SetOutput(io.Discard)
get(newTestServer(nil), root+"instance/service-accounts/default/token?scopes=secret-scope")
if !strings.Contains(logged.String(), "GET "+root+"instance/service-accounts/default/token 200") {
t.Errorf("the request was not logged:\n%s", logged.String())
}
if strings.Contains(logged.String(), "secret-scope") {
t.Errorf("the log contains the query:\n%s", logged.String())
}
}
func TestHeadHasNoBody(t *testing.T) {
server := httptest.NewServer(newTestServer(nil))
defer server.Close()
request, _ := http.NewRequest(http.MethodHead, server.URL+root+"project/project-id", nil)
request.Header.Set("Metadata-Flavor", "Google")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != 200 || len(body) != 0 {
t.Errorf("got %d with body %q, want 200 without body", response.StatusCode, body)
}
}
func TestPing(t *testing.T) {
emulator := httptest.NewServer(newTestServer(nil))
defer emulator.Close()
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer other.Close()
closed := httptest.NewServer(nil)
closed.Close()
if err := ping(strings.TrimPrefix(emulator.URL, "http://")); err != nil {
t.Errorf("ping(emulator) = %v, want nil", err)
}
if err := ping(strings.TrimPrefix(other.URL, "http://")); err == nil {
t.Error("ping(other server) = nil, want an error")
}
if err := ping(strings.TrimPrefix(closed.URL, "http://")); err == nil {
t.Error("ping(closed) = nil, want an error")
}
}
func TestTokenExpiry(t *testing.T) {
tests := []struct {
name string
token auth.Token
wantType string
wantIn int
}{
{"remaining lifetime", auth.Token{Value: "t", Expiry: testNow.Add(90 * time.Second)}, "Bearer", 90},
{"no expiry", auth.Token{Value: "t"}, "Bearer", 3600},
{"expired", auth.Token{Value: "t", Expiry: testNow.Add(-time.Minute)}, "Bearer", 0},
{"token type", auth.Token{Value: "t", Type: "MAC", Expiry: testNow.Add(time.Minute)}, "MAC", 60},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := newTestServer(nil)
server.token = func(context.Context, []string) (*auth.Token, error) { return &test.token, nil }
var response struct {
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
recorder := get(server, root+"instance/service-accounts/default/token")
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("invalid JSON %q: %v", recorder.Body.String(), err)
}
if response.ExpiresIn != test.wantIn || response.TokenType != test.wantType {
t.Errorf("got expires_in %d token_type %q, want %d %q", response.ExpiresIn, response.TokenType, test.wantIn, test.wantType)
}
})
}
}
func TestTokenError(t *testing.T) {
server := newTestServer(nil)
server.token = func(context.Context, []string) (*auth.Token, error) { return nil, errors.New("secret detail") }
recorder := get(server, root+"instance/service-accounts/default/token")
if recorder.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", recorder.Code)
}
if strings.Contains(recorder.Body.String(), "secret detail") {
t.Errorf("the response %q leaks the error", recorder.Body.String())
}
}
func TestServiceAccountEmail(t *testing.T) {
tests := []struct {
name string
json string
want string
}{
{"external account", `{"type":"external_account","service_account_impersonation_url":"https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/sa-test@dom-lab.iam.gserviceaccount.com:generateAccessToken"}`, testEmail},
{"impersonated account", `{"type":"impersonated_service_account","service_account_impersonation_url":"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa-test@dom-lab.iam.gserviceaccount.com:generateAccessToken"}`, testEmail},
{"service account key", `{"type":"service_account","client_email":"sa-test@dom-lab.iam.gserviceaccount.com"}`, testEmail},
{"impersonation wins", `{"client_email":"key@p.iam.gserviceaccount.com","service_account_impersonation_url":"https://x/serviceAccounts/sa-test@dom-lab.iam.gserviceaccount.com:generateAccessToken"}`, testEmail},
{"external account without impersonation", `{"type":"external_account"}`, ""},
{"unexpected impersonation URL", `{"service_account_impersonation_url":"https://x/other"}`, ""},
{"invalid JSON", `{`, ""},
{"empty", ``, ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := serviceAccountEmail([]byte(test.json)); got != test.want {
t.Errorf("got %q, want %q", got, test.want)
}
})
}
}
func TestProviderCache(t *testing.T) {
var detected atomic.Int32
var failures atomic.Int32
cache := &providerCache{detect: func(scopes []string) (auth.TokenProvider, error) {
detected.Add(1)
if slices.Equal(scopes, []string{"fail"}) && failures.Add(1) == 1 {
return nil, errors.New("detection failed")
}
return tokenProviderFunc(func(context.Context) (*auth.Token, error) {
return &auth.Token{Value: strings.Join(scopes, ",")}, nil
}), nil
}}
ctx := context.Background()
for _, scopes := range [][]string{{"a", "b"}, {"b", "a"}, {"a", "b"}} {
token, err := cache.token(ctx, scopes)
if err != nil || token.Value != "a,b" {
t.Fatalf("token(%v) = %v, %v; want a,b", scopes, token, err)
}
}
if got := detected.Load(); got != 1 {
t.Errorf("detected %d times for the same scopes in any order, want 1", got)
}
if _, err := cache.token(ctx, []string{"c"}); err != nil {
t.Fatal(err)
}
if got := detected.Load(); got != 2 {
t.Errorf("detected %d times after other scopes, want 2", got)
}
if _, err := cache.token(ctx, []string{"fail"}); err == nil {
t.Error("the detection error was not returned")
}
if _, err := cache.token(ctx, []string{"fail"}); err != nil {
t.Errorf("the detection error was cached: %v", err)
}
}
func TestProviderCacheBound(t *testing.T) {
cache := &providerCache{detect: func([]string) (auth.TokenProvider, error) {
return tokenProviderFunc(func(context.Context) (*auth.Token, error) { return &auth.Token{Value: "t"}, nil }), nil
}}
for i := range 10 * maxProviders {
if _, err := cache.token(context.Background(), []string{strings.Repeat("s", i+1)}); err != nil {
t.Fatal(err)
}
if len(cache.providers) > maxProviders {
t.Fatalf("%d providers cached after %d scope sets, want at most %d", len(cache.providers), i+1, maxProviders)
}
}
}
func TestProviderCacheConcurrent(t *testing.T) {
var detected atomic.Int32
cache := &providerCache{detect: func([]string) (auth.TokenProvider, error) {
detected.Add(1)
return tokenProviderFunc(func(context.Context) (*auth.Token, error) { return &auth.Token{Value: "t"}, nil }), nil
}}
var wg sync.WaitGroup
for range 32 {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := cache.token(context.Background(), []string{"a"}); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if got := detected.Load(); got != 1 {
t.Errorf("detected %d times, want 1", got)
}
}
// startServe serves the handler on a local listener and returns its address,
// the function that stops it and the channel with the result of serve.
func startServe(t *testing.T, handler http.Handler, grace time.Duration) (string, context.CancelFunc, <-chan error) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
result := make(chan error, 1)
go func() {
result <- serve(ctx, &http.Server{Handler: handler}, listener, grace)
}()
return listener.Addr().String(), cancel, result
}
// await returns the result of serve, failing the test when it takes too long.
func await(t *testing.T, result <-chan error) error {
t.Helper()
select {
case err := <-result:
return err
case <-time.After(5 * time.Second):
t.Fatal("serve did not return")
return nil
}
}
func TestServeStopsOnCancel(t *testing.T) {
address, cancel, result := startServe(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), time.Second)
response, err := http.Get("http://" + address)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
cancel()
if err := await(t, result); err != nil {
t.Errorf("serve = %v, want nil", err)
}
if connection, err := net.Dial("tcp", address); err == nil {
connection.Close()
t.Error("the listener still accepts connections")
}
}
func TestServeFinishesRequestsInProgress(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
address, cancel, result := startServe(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
io.WriteString(w, "done")
}), 5*time.Second)
body := make(chan string, 1)
go func() {
response, err := http.Get("http://" + address)
if err != nil {
body <- err.Error()
return
}
defer response.Body.Close()
data, _ := io.ReadAll(response.Body)
body <- string(data)
}()
<-started
cancel()
select {
case err := <-result:
t.Fatalf("serve returned %v while a request was in progress", err)
case <-time.After(200 * time.Millisecond):
}
close(release)
if got := <-body; got != "done" {
t.Errorf("response = %q, want done", got)
}
if err := await(t, result); err != nil {
t.Errorf("serve = %v, want nil", err)
}
}
func TestServeStopsAfterGracePeriod(t *testing.T) {
started := make(chan struct{})
address, cancel, result := startServe(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-r.Context().Done()
}), 100*time.Millisecond)
failed := make(chan error, 1)
go func() {
response, err := http.Get("http://" + address)
if err == nil {
response.Body.Close()
}
failed <- err
}()
<-started
cancel()
if err := await(t, result); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("serve = %v, want %v", err, context.DeadlineExceeded)
}
select {
case err := <-failed:
if err == nil {
t.Error("the request in progress was not interrupted")
}
case <-time.After(5 * time.Second):
t.Error("the request in progress was not interrupted")
}
}
func TestServeReturnsListenerFailure(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listener.Close()
if err := serve(context.Background(), &http.Server{}, listener, time.Second); err == nil {
t.Error("serve = nil, want the listener error")
}
}
// writeServiceAccountKey writes a service account key with a generated private key.
func writeServiceAccountKey(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
content, err := json.Marshal(map[string]string{
"type": "service_account",
"project_id": "dom-lab",
"private_key_id": "key",
"private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})),
"client_email": testEmail,
"client_id": "1",
"token_uri": "https://oauth2.googleapis.com/token",
})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "sa.json")
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
return path
}
// safeBuffer collects the output of a process while it runs.
type safeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *safeBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *safeBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// emulator is the test binary running as the emulator process.
type emulator struct {
address string
process *os.Process
exited <-chan error
output *safeBuffer
}
// freePort returns a free TCP port of the loopback interface.
func freePort(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
_, port, _ := net.SplitHostPort(listener.Addr().String())
return port
}
// runEmulator starts the test binary as the emulator with the arguments and
// the environment on the loopback interface, and returns it once it answers.
func runEmulator(t *testing.T, env []string, args ...string) *emulator {
t.Helper()
port := freePort(t)
e := &emulator{address: "127.0.0.1:" + port, output: &safeBuffer{}}
command := exec.Command(os.Args[0], args...)
command.Env = append(os.Environ(), mainEnv+"=1", "GOOGLE_APPLICATION_CREDENTIALS="+writeServiceAccountKey(t), "IMDS_HOST=127.0.0.1", "IMDS_PORT="+port)
command.Env = append(command.Env, env...)
command.Stderr = e.output
if err := command.Start(); err != nil {
t.Fatal(err)
}
exited := make(chan error, 1)
go func() { exited <- command.Wait() }()
e.process, e.exited = command.Process, exited
t.Cleanup(func() { command.Process.Kill() })
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); {
select {
case err := <-exited:
t.Fatalf("the emulator exited before it was ready: %v\n%s", err, e.output.String())
default:
}
if ping(e.address) == nil {
return e
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("the emulator did not answer\n%s", e.output.String())
return nil
}
// wait returns the exit error of the emulator, failing the test when it does not stop.
func (e *emulator) wait(t *testing.T) error {
t.Helper()
select {
case err := <-e.exited:
return err
case <-time.After(10 * time.Second):
t.Fatalf("the emulator did not stop\n%s", e.output.String())
return nil
}
}
// runCheck runs the test binary in check mode against the port and returns its exit error.
func runCheck(t *testing.T, port string) error {
t.Helper()
command := exec.Command(os.Args[0], "-check")
command.Env = append(os.Environ(), mainEnv+"=1", "IMDS_PORT="+port)
return command.Run()
}
// TestSignals runs the emulator as a process and stops it with signals.
func TestSignals(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
for _, signal := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT} {
t.Run(signal.String(), func(t *testing.T) {
e := runEmulator(t, nil)
if err := e.process.Signal(signal); err != nil {
t.Fatal(err)
}
if err := e.wait(t); err != nil {
t.Errorf("the emulator exited with %v, want status 0\n%s", err, e.output.String())
}
if !strings.Contains(e.output.String(), "shutting down") || !strings.Contains(e.output.String(), "Stopped") {
t.Errorf("unexpected log:\n%s", e.output.String())
}
})
}
t.Run("second signal", func(t *testing.T) {
e := runEmulator(t, nil)
// A partial request keeps a connection active, so the shutdown waits for it.
connection, err := net.Dial("tcp", e.address)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
if _, err := io.WriteString(connection, "GET / HTTP/1.1\r\nHost: imds\r\n"); err != nil {
t.Fatal(err)
}
if err := e.process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
select {
case err := <-e.exited:
t.Fatalf("the emulator exited with %v while a connection was active", err)
case <-time.After(500 * time.Millisecond):
}
if err := e.process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
var exitError *exec.ExitError
if err := e.wait(t); !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Errorf("the emulator exited with %v, want status 1\n%s", err, e.output.String())
}
if !strings.Contains(e.output.String(), "again, exiting") {
t.Errorf("unexpected log:\n%s", e.output.String())
}
})
}
// TestProcess checks the environment handling and the check mode of the emulator process.
func TestProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signals")
}
email := func(t *testing.T, address string) string {
t.Helper()
request, _ := http.NewRequest(http.MethodGet, "http://"+address+root+"instance/service-accounts/default/email", nil)
request.Header.Set("Metadata-Flavor", "Google")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
return string(body)
}
t.Run("email from credentials", func(t *testing.T) {
e := runEmulator(t, nil)
if got := email(t, e.address); got != testEmail {
t.Errorf("email = %q, want %q", got, testEmail)
}
if !strings.Contains(e.output.String(), "Serving tokens of "+testEmail+" for project dom-lab on "+e.address) {
t.Errorf("unexpected log:\n%s", e.output.String())
}
})
t.Run("email override", func(t *testing.T) {
e := runEmulator(t, []string{"SERVICE_ACCOUNT_EMAIL=other@dom-lab.iam.gserviceaccount.com"})
if got := email(t, e.address); got != "other@dom-lab.iam.gserviceaccount.com" {
t.Errorf("email = %q, want the override", got)
}
})
t.Run("check", func(t *testing.T) {
e := runEmulator(t, nil)
_, port, _ := net.SplitHostPort(e.address)
if err := runCheck(t, port); err != nil {
t.Errorf("check against the emulator = %v, want nil", err)
}
if err := runCheck(t, freePort(t)); err == nil {
t.Error("check against a closed port = nil, want an error")
}
})
}