Moved IMDS Emulator/Proxy sources to the sts-agent folder.

This commit is contained in:
slawek committed 2026-10-20 08:32:24 +02:00
1 parent 850a85f33a
commit 9187aaab97
5 files changed
+2 -2

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
FROM python:3.13-slim
RUN pip install --no-cache-dir "google-auth[requests,cryptography]>=2.39.0"
COPY imds_emulator.py /usr/local/bin/imds-emulator
COPY imds_test.py /usr/local/bin/imds-test
CMD ["/usr/local/bin/imds-emulator"]
+1 -1
View File
@@ -1,6 +1,6 @@
services:
imds-emulator:
build: ../imds-emulator
build: .
volumes:
- /etc/sts-agent:/etc/sts-agent:ro
environment:
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Compute Engine metadata server (IMDS) emulator.
Serves access tokens of the credentials found by Application Default
Credentials (ADC) on the IMDS token endpoint, together with the metadata
that Google client libraries read when they authenticate on Compute Engine.
"""
import datetime
import http.server
import json
import os
import socket
import sys
import threading
import urllib.parse
import google.auth
import google.auth.credentials
import google.auth.transport.requests
DEFAULT_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
ROOT = "/computeMetadata/v1/"
port = int(os.environ.get("IMDS_PORT", "80"))
hostname = os.environ.get("IMDS_HOSTNAME", socket.gethostname())
project_number = os.environ.get("PROJECT_NUMBER", "")
base_credentials, adc_project_id = google.auth.default(scopes=[DEFAULT_SCOPE])
project_id = os.environ.get("PROJECT_ID") or adc_project_id or ""
email = getattr(base_credentials, "service_account_email", None) or os.environ.get("SERVICE_ACCOUNT_EMAIL", "")
if not email:
sys.exit("The service account email is unknown, set SERVICE_ACCOUNT_EMAIL")
credentials_by_scopes = {}
lock = threading.Lock()
def token(scopes):
"""
Returns the access token and its remaining lifetime in seconds for the
scopes, refreshing the cached credentials of the scopes when needed.
"""
key = tuple(sorted(scopes))
with lock:
credentials = credentials_by_scopes.get(key)
if credentials is None:
credentials = google.auth.credentials.with_scopes_if_required(base_credentials, list(key))
credentials_by_scopes[key] = credentials
if not credentials.valid:
credentials.refresh(google.auth.transport.requests.Request())
expires_in = int((credentials.expiry - datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)).total_seconds())
return credentials.token, expires_in
class Handler(http.server.BaseHTTPRequestHandler):
"""
Answers the IMDS paths used by the Google client libraries, and 404 for
any other path.
"""
server_version = "Metadata Server for VM"
sys_version = ""
def reply(self, status, body, content_type="application/text"):
data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
self.send_response(status)
self.send_header("Metadata-Flavor", "Google")
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
url = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(url.query)
# The real IMDS rejects requests without the flavor header and proxied requests.
if self.headers.get("Metadata-Flavor") != "Google" or self.headers.get("X-Forwarded-For"):
return self.reply(403, "Forbidden")
if url.path in ("/", ROOT):
return self.reply(200, "")
if not url.path.startswith(ROOT):
return self.reply(404, "Not Found")
path = url.path[len(ROOT):]
# Both "default" and the email address name the service account.
for alias in ("default", email):
prefix = f"instance/service-accounts/{alias}/"
if path.startswith(prefix):
path = "instance/service-accounts/default/" + path[len(prefix):]
break
account = {"aliases": ["default"], "email": email, "scopes": [DEFAULT_SCOPE]}
if path == "instance/service-accounts/default/token":
scopes = query.get("scopes", [DEFAULT_SCOPE])[0].split(",")
access_token, expires_in = token(scopes)
return self.reply(200, {"access_token": access_token, "expires_in": expires_in, "token_type": "Bearer"}, "application/json")
if path == "instance/service-accounts/default/email":
return self.reply(200, email)
if path == "instance/service-accounts/default/scopes":
return self.reply(200, DEFAULT_SCOPE + "\n")
if path == "instance/service-accounts/default/" and query.get("recursive") == ["true"]:
return self.reply(200, account, "application/json")
if path == "instance/service-accounts/" and query.get("recursive") == ["true"]:
return self.reply(200, {"default": account, email: account}, "application/json")
if path == "instance/service-accounts/":
return self.reply(200, f"default/\n{email}/\n")
if path == "project/project-id" and project_id:
return self.reply(200, project_id)
if path == "project/numeric-project-id" and project_number:
return self.reply(200, project_number)
if path == "instance/hostname":
return self.reply(200, hostname)
if path == "universe/universe-domain":
return self.reply(200, "googleapis.com")
return self.reply(404, "Not Found")
def log_request(self, code="-", size="-"):
print(f"{self.command} {urllib.parse.urlsplit(self.path).path} {code}", flush=True)
print(f"Serving tokens of {email} for project {project_id or '-'} on port {port}", flush=True)
http.server.ThreadingHTTPServer(("", port), Handler).serve_forever()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Tests the IMDS emulator: the metadata endpoints, the request checks, the
Compute Engine credentials of google-auth on top of the emulator, and the
validity of the issued access token at Google.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
parser = argparse.ArgumentParser(description="Test the IMDS emulator.")
parser.add_argument("--host", default="localhost", help="host and optional port of the emulator")
parser.add_argument("--bucket", help="bucket to list with the token, optional")
args = parser.parse_args()
root = f"http://{args.host}/computeMetadata/v1/"
failures = 0
def check(name, condition, detail=""):
"""
Prints the result of a check and counts the failures.
"""
global failures
print(f"{'PASS' if condition else 'FAIL'} {name}{': ' + detail if detail else ''}")
if not condition:
failures += 1
def get(url, headers=None):
"""
Returns the status, the headers and the body of a GET request.
"""
request = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, response.headers, response.read().decode()
except urllib.error.HTTPError as error:
return error.code, error.headers, error.read().decode()
flavor = {"Metadata-Flavor": "Google"}
# Detection ping and request checks
status, headers, _ = get(f"http://{args.host}/", flavor)
check("ping", status == 200 and headers.get("Metadata-Flavor") == "Google", f"status {status}")
status, _, _ = get(root + "project/project-id")
check("request without Metadata-Flavor is rejected", status == 403, f"status {status}")
status, _, _ = get(root + "project/project-id", {**flavor, "X-Forwarded-For": "192.0.2.1"})
check("request with X-Forwarded-For is rejected", status == 403, f"status {status}")
# Metadata
status, _, project_id = get(root + "project/project-id", flavor)
check("project ID", status == 200 and project_id != "", project_id)
status, _, email = get(root + "instance/service-accounts/default/email", flavor)
check("service account email", status == 200 and "@" in email, email)
status, _, body = get(root + "instance/service-accounts/default/?recursive=true", flavor)
check("service account info", status == 200 and json.loads(body).get("email") == email, body)
status, _, body = get(root + f"instance/service-accounts/{email}/email", flavor)
check("service account by email", status == 200 and body == email, body)
# Token
status, _, body = get(root + "instance/service-accounts/default/token", flavor)
token = json.loads(body) if status == 200 else {}
check("token", status == 200 and token.get("access_token") and token.get("expires_in", 0) > 0, f"expires in {token.get('expires_in')} s")
# Compute Engine credentials of google-auth, the path used by the STS agent
for name in ("GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_API_CERTIFICATE_CONFIG"):
os.environ.pop(name, None)
os.environ["GCE_METADATA_HOST"] = args.host
os.environ["GCE_METADATA_IP"] = args.host
import google.auth
import google.auth.transport.requests
credentials, adc_project_id = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
credentials.refresh(google.auth.transport.requests.Request())
check("google-auth uses Compute Engine credentials", type(credentials).__module__ == "google.auth.compute_engine.credentials", type(credentials).__module__)
check("google-auth project ID", adc_project_id == project_id, str(adc_project_id))
check("google-auth service account", credentials.service_account_email == email, credentials.service_account_email)
check("google-auth token", credentials.valid, f"expires {credentials.expiry} UTC")
# Validity of the token at Google
status, _, body = get("https://oauth2.googleapis.com/tokeninfo?" + urllib.parse.urlencode({"access_token": credentials.token}))
info = json.loads(body) if status == 200 else {}
check("token accepted by Google", status == 200, f"scope {info.get('scope')}, expires in {info.get('expires_in')} s")
# Use of the token with Cloud Storage
if args.bucket:
status, _, body = get(
f"https://storage.googleapis.com/storage/v1/b/{urllib.parse.quote(args.bucket)}/o?maxResults=1",
{"Authorization": f"Bearer {credentials.token}"},
)
check(f"list objects of {args.bucket}", status == 200, f"status {status}")
print(f"{failures} check(s) failed" if failures else "All checks passed")
sys.exit(1 if failures else 0)