342 lines
11 KiB
Go
342 lines
11 KiB
Go
// 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 zones for the log timestamps without the files of the system.
|
|
|
|
"cloud.google.com/go/auth"
|
|
"cloud.google.com/go/auth/credentials"
|
|
_ "golang.org/x/crypto/x509roots/fallback" // Root certificates without the files of the system.
|
|
)
|
|
|
|
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")
|
|
}
|