| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you 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. | |
| import argparse | |
| import json | |
| import logging | |
| import os | |
| import socket | |
| import ssl | |
| import threading | |
| import time | |
| from http.server import HTTPServer | |
| from socketserver import ThreadingMixIn | |
| from typing import Type | |
| try: | |
| from http.server import ThreadingHTTPServer | |
| except ImportError: | |
| class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): # type: ignore[no-redef] | |
| pass | |
| from .config import TransferRegistry, validate_transfer_config | |
| from .constants import ( | |
| CONTROL_RECV_BUFFER, | |
| CONTROL_SOCKET, | |
| CONTROL_SOCKET_BACKLOG, | |
| CONTROL_SOCKET_PERMISSIONS, | |
| DEFAULT_HTTP_PORT, | |
| DEFAULT_LISTEN_ADDRESS, | |
| LOGGING_LEVEL | |
| ) | |
| from .handler import Handler | |
| def make_handler( | |
| registry: TransferRegistry, | |
| ) -> Type[Handler]: | |
| """ | |
| Create a Handler subclass with injected dependencies. | |
| BaseHTTPRequestHandler is instantiated per-request by the server, so we | |
| cannot pass constructor args. Instead, we set class-level attributes. | |
| """ | |
| class ConfiguredHandler(Handler): | |
| _registry = registry | |
| return ConfiguredHandler | |
| def _handle_control_conn(conn: socket.socket, registry: TransferRegistry) -> None: | |
| """Handle a single control-socket connection (one JSON request/response).""" | |
| try: | |
| data = b"" | |
| while True: | |
| chunk = conn.recv(CONTROL_RECV_BUFFER) | |
| if not chunk: | |
| break | |
| data += chunk | |
| if b"\n" in data: | |
| break | |
| msg = json.loads(data.strip()) | |
| action = msg.get("action") | |
| if action == "register": | |
| transfer_id = msg.get("transfer_id") | |
| raw_config = msg.get("config") | |
| if not transfer_id or not isinstance(raw_config, dict): | |
| resp = {"status": "error", "message": "missing transfer_id or config"} | |
| else: | |
| try: | |
| config = validate_transfer_config(raw_config) | |
| except ValueError as e: | |
| resp = {"status": "error", "message": str(e)} | |
| else: | |
| if registry.register(transfer_id, config): | |
| resp = {"status": "ok", "active_transfers": registry.active_count()} | |
| else: | |
| resp = {"status": "error", "message": "invalid transfer_id"} | |
| elif action == "unregister": | |
| transfer_id = msg.get("transfer_id") | |
| if not transfer_id: | |
| resp = {"status": "error", "message": "missing transfer_id"} | |
| else: | |
| remaining = registry.unregister(transfer_id) | |
| resp = {"status": "ok", "active_transfers": remaining} | |
| elif action == "status": | |
| resp = {"status": "ok", "active_transfers": registry.active_count()} | |
| else: | |
| resp = {"status": "error", "message": f"unknown action: {action}"} | |
| conn.sendall((json.dumps(resp) + "\n").encode("utf-8")) | |
| except Exception as e: | |
| logging.error("control socket error: %r", e) | |
| try: | |
| conn.sendall((json.dumps({"status": "error", "message": str(e)}) + "\n").encode("utf-8")) | |
| except Exception: | |
| pass | |
| finally: | |
| conn.close() | |
| def _idle_sweep_loop(registry: TransferRegistry, interval_s: float = 10.0) -> None: | |
| while True: | |
| time.sleep(interval_s) | |
| try: | |
| registry.sweep_expired_transfers() | |
| except Exception: | |
| logging.exception("idle sweep error") | |
| def _control_listener(registry: TransferRegistry, sock_path: str) -> None: | |
| """Accept loop for the Unix domain control socket (runs in a daemon thread).""" | |
| if os.path.exists(sock_path): | |
| os.unlink(sock_path) | |
| os.makedirs(os.path.dirname(sock_path), exist_ok=True) | |
| srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | |
| srv.bind(sock_path) | |
| os.chmod(sock_path, CONTROL_SOCKET_PERMISSIONS) | |
| srv.listen(CONTROL_SOCKET_BACKLOG) | |
| logging.info("control socket listening on %s", sock_path) | |
| while True: | |
| conn, _ = srv.accept() | |
| threading.Thread( | |
| target=_handle_control_conn, | |
| args=(conn, registry), | |
| daemon=True, | |
| ).start() | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="CloudStack image server backed by NBD / local file" | |
| ) | |
| parser.add_argument("--listen", default=DEFAULT_LISTEN_ADDRESS, help="Address to bind") | |
| parser.add_argument("--port", type=int, default=DEFAULT_HTTP_PORT, help="Port to listen on") | |
| parser.add_argument( | |
| "--control-socket", | |
| default=CONTROL_SOCKET, | |
| help="Path to the Unix domain control socket", | |
| ) | |
| parser.add_argument( | |
| "--tls-enabled", | |
| action="store_true", | |
| help="Enable TLS for the HTTP transfer endpoint", | |
| ) | |
| parser.add_argument( | |
| "--tls-cert-file", | |
| default=None, | |
| help="Path to PEM certificate file used when TLS is enabled", | |
| ) | |
| parser.add_argument( | |
| "--tls-key-file", | |
| default=None, | |
| help="Path to PEM private key file used when TLS is enabled", | |
| ) | |
| args = parser.parse_args() | |
| if args.tls_enabled and (not args.tls_cert_file or not args.tls_key_file): | |
| parser.error("--tls-enabled requires --tls-cert-file and --tls-key-file") | |
| logging.basicConfig( | |
| level=LOGGING_LEVEL, | |
| format="%(asctime)s %(levelname)s %(message)s", | |
| ) | |
| registry = TransferRegistry() | |
| handler_cls = make_handler(registry) | |
| ctrl_thread = threading.Thread( | |
| target=_control_listener, | |
| args=(registry, args.control_socket), | |
| daemon=True, | |
| ) | |
| ctrl_thread.start() | |
| sweep_thread = threading.Thread( | |
| target=_idle_sweep_loop, | |
| args=(registry,), | |
| daemon=True, | |
| ) | |
| sweep_thread.start() | |
| addr = (args.listen, args.port) | |
| httpd = ThreadingHTTPServer(addr, handler_cls) | |
| scheme = "http" | |
| if args.tls_enabled: | |
| context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) | |
| if hasattr(ssl, "TLSVersion") and hasattr(context, "minimum_version"): | |
| context.minimum_version = ssl.TLSVersion.TLSv1_2 | |
| else: | |
| if hasattr(ssl, "OP_NO_TLSv1"): | |
| context.options |= ssl.OP_NO_TLSv1 | |
| if hasattr(ssl, "OP_NO_TLSv1_1"): | |
| context.options |= ssl.OP_NO_TLSv1_1 | |
| context.load_cert_chain(certfile=args.tls_cert_file, keyfile=args.tls_key_file) | |
| httpd.socket = context.wrap_socket(httpd.socket, server_side=True) | |
| scheme = "https" | |
| logging.info("listening on %s://%s:%d", scheme, args.listen, args.port) | |
| httpd.serve_forever() |