| # 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() |