| new file mode 100644 |
| index 000000000..47d7796bc |
| --- /dev/null |
| +++ b/harness/stand-ins/imds.py |
| @@ -0,0 +1,203 @@ |
| +#!/usr/bin/env python3 |
| +"""IMDS stand-in for harness boxes (spec §3.5). Python 3 standard library only. |
| + |
| +Binds 203.0.113.1:8169; the host's nftables DNATs 169.254.169.254:80 from the |
| +bridge here, so the source address is the box's own bridge address, and every |
| +answer is keyed by it. IMDSv2 only: a GET needs a token from PUT |
| +/latest/api/token, and a token is bound to the address that asked for it. |
| + |
| +Config /etc/harness/imds.json: |
| + {"203.0.113.21": {"box": "leo", "donor": "/etc/harness/donors/donor-1", |
| + "role": "carlos-dev-host-b", "user": "leo"}, ...} |
| +A donor directory holds AWS's `document` and `signature`, served byte for byte. |
| +`user` names an entry in /root/harness/minio-users.json, whose keys are |
| +served as the role's credentials. Every request is logged as one JSON line to |
| +/var/log/harness/imds.jsonl (paths and status; never a key). |
| +""" |
| +import datetime, http.server, json, os, secrets, sys, threading, time |
| + |
| +CONFIG = os.environ.get("IMDS_CONFIG", "/etc/harness/imds.json") |
| +USERS = os.environ.get("IMDS_USERS", "/root/harness/minio-users.json") |
| +LOG = os.environ.get("IMDS_LOG", "/var/log/harness/imds.jsonl") |
| +BIND = (os.environ.get("IMDS_ADDR", "203.0.113.1"), int(os.environ.get("IMDS_PORT", "8169"))) |
| +VPC_CIDR = "203.0.113.0/24" |
| + |
| +tokens = {} # token -> (source address, expiry) |
| +lock = threading.Lock() |
| + |
| + |
| +def load(): |
| + with open(CONFIG) as f: |
| + cfg = json.load(f) |
| + with open(USERS) as f: |
| + users = json.load(f) |
| + return cfg, users |
| + |
| + |
| +def mac_for(addr): |
| + # A stable, locally administered MAC per box (the real one is the ENI's). |
| + last = int(addr.rsplit(".", 1)[1]) |
| + return "06:00:cb:00:71:%02x" % last |
| + |
| + |
| +def leaves(addr, box, users): |
| + """The metadata tree for one box, path (no leading /latest) -> body.""" |
| + out = {} |
| + doc = sig = None |
| + if box.get("donor"): |
| + with open(os.path.join(box["donor"], "document"), "rb") as f: |
| + doc = f.read() |
| + with open(os.path.join(box["donor"], "signature"), "rb") as f: |
| + sig = f.read() |
| + d = json.loads(doc) |
| + out["meta-data/instance-id"] = d["instanceId"] |
| + out["meta-data/ami-id"] = d.get("imageId", "") |
| + out["meta-data/instance-type"] = d.get("instanceType", "") |
| + out["meta-data/placement/region"] = d["region"] |
| + out["meta-data/placement/availability-zone"] = d.get("availabilityZone", "") |
| + out["dynamic/instance-identity/document"] = doc |
| + out["dynamic/instance-identity/signature"] = sig |
| + else: |
| + out["meta-data/placement/region"] = "eu-west-1" |
| + mac = mac_for(addr) |
| + out["meta-data/local-ipv4"] = addr |
| + out["meta-data/public-ipv4"] = addr |
| + out["meta-data/mac"] = mac |
| + out[f"meta-data/network/interfaces/macs/{mac}/vpc-ipv4-cidr-blocks"] = VPC_CIDR |
| + out["meta-data/hostname"] = box.get("box", "") |
| + out["meta-data/local-hostname"] = box.get("box", "") |
| + role = box.get("role") |
| + if role and box.get("user") in users: |
| + u = users[box["user"]] |
| + now = datetime.datetime.now(datetime.timezone.utc) |
| + out["meta-data/iam/security-credentials/"] = role |
| + out[f"meta-data/iam/security-credentials/{role}"] = json.dumps({ |
| + "Code": "Success", |
| + "LastUpdated": now.strftime("%Y-%m-%dT%H:%M:%SZ"), |
| + "Type": "AWS-HMAC", |
| + "AccessKeyId": u["access_key"], |
| + "SecretAccessKey": u["secret_key"], |
| + "Token": "", |
| + "Expiration": (now + datetime.timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ"), |
| + }, indent=2) |
| + out["meta-data/iam/info"] = json.dumps({ |
| + "Code": "Success", "LastUpdated": now.strftime("%Y-%m-%dT%H:%M:%SZ"), |
| + "InstanceProfileArn": f"arn:aws:iam::640012953448:instance-profile/{role}", |
| + "InstanceProfileId": "AIPAHARNESS" + box.get("box", "").upper()}) |
| + return out |
| + |
| + |
| +def listing(tree, prefix): |
| + """Directory listing in IMDS style: immediate children, dirs with a slash.""" |
| + kids = set() |
| + for p in tree: |
| + if p.startswith(prefix) and p != prefix: |
| + rest = p[len(prefix):] |
| + if not rest: |
| + continue |
| + head, sep, _ = rest.partition("/") |
| + kids.add(head + ("/" if sep else "")) |
| + return "\n".join(sorted(kids)) |
| + |
| + |
| +class H(http.server.BaseHTTPRequestHandler): |
| + protocol_version = "HTTP/1.1" |
| + server_version = "EC2ws" |
| + |
| + def log(self, status, note=""): |
| + rec = {"t": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "src": self.client_address[0], |
| + "method": self.command, "path": self.path, "status": status} |
| + if note: |
| + rec["note"] = note |
| + try: |
| + with open(LOG, "a") as f: |
| + f.write(json.dumps(rec) + "\n") |
| + except OSError: |
| + pass |
| + |
| + def reply(self, status, body=b"", ctype="text/plain"): |
| + if isinstance(body, str): |
| + body = body.encode() |
| + self.send_response(status) |
| + self.send_header("Content-Type", ctype) |
| + self.send_header("Content-Length", str(len(body))) |
| + self.end_headers() |
| + if self.command != "HEAD": |
| + self.wfile.write(body) |
| + self.log(status) |
| + |
| + def do_PUT(self): |
| + n = int(self.headers.get("Content-Length") or 0) |
| + if n: |
| + self.rfile.read(n) |
| + if self.path != "/latest/api/token": |
| + return self.reply(404) |
| + if self.headers.get("X-Forwarded-For"): |
| + return self.reply(403) |
| + try: |
| + ttl = int(self.headers.get("X-aws-ec2-metadata-token-ttl-seconds", "")) |
| + except ValueError: |
| + return self.reply(400) |
| + if not 1 <= ttl <= 21600: |
| + return self.reply(400) |
| + tok = secrets.token_urlsafe(42) |
| + with lock: |
| + now = time.time() |
| + for k in [k for k, v in tokens.items() if v[1] < now]: |
| + del tokens[k] |
| + tokens[tok] = (self.client_address[0], now + ttl) |
| + self.send_response(200) |
| + self.send_header("X-aws-ec2-metadata-token-ttl-seconds", str(ttl)) |
| + self.send_header("Content-Type", "text/plain") |
| + self.send_header("Content-Length", str(len(tok))) |
| + self.end_headers() |
| + self.wfile.write(tok.encode()) |
| + self.log(200) |
| + |
| + def do_GET(self): |
| + src = self.client_address[0] |
| + tok = self.headers.get("X-aws-ec2-metadata-token", "") |
| + with lock: |
| + ent = tokens.get(tok) |
| + if not ent or ent[0] != src or ent[1] < time.time(): |
| + return self.reply(401) |
| + try: |
| + cfg, users = load() |
| + except (OSError, ValueError) as e: |
| + self.log(500, f"config: {e}") |
| + return self.reply(500) |
| + box = cfg.get(src) |
| + if box is None: |
| + return self.reply(404) |
| + path = self.path.split("?", 1)[0] |
| + if not path.startswith("/latest/"): |
| + return self.reply(404) |
| + p = path[len("/latest/"):] |
| + tree = leaves(src, box, users) |
| + if p in tree: |
| + body = tree[p] |
| + ctype = "application/json" if p.endswith(("/document", "info")) or (p.startswith("meta-data/iam/security-credentials/") and not p.endswith("/")) else "text/plain" |
| + return self.reply(200, body, ctype) |
| + if p == "" or p.endswith("/") or any(k.startswith(p + "/") for k in tree): |
| + pre = p if (p == "" or p.endswith("/")) else p + "/" |
| + ls = listing(tree, pre) |
| + if ls: |
| + return self.reply(200, ls) |
| + return self.reply(404) |
| + |
| + do_HEAD = do_GET |
| + |
| + def log_message(self, *a): |
| + pass |
| + |
| + |
| +def main(): |
| + os.makedirs(os.path.dirname(LOG), exist_ok=True) |
| + load() # fail at start, not at the first request |
| + srv = http.server.ThreadingHTTPServer(BIND, H) |
| + print(f"imds stand-in on {BIND[0]}:{BIND[1]}", flush=True) |
| + srv.serve_forever() |
| + |
| + |
| +if __name__ == "__main__": |
| + sys.exit(main()) |