mirror of
https://github.com/cloudflare/cloudflared.git
synced 2025-05-12 15:06:34 +00:00

With the management tunnels work, we allow calls to our edge service using an access JWT provided by Tunnelstore. Given a connector ID, this request is then proxied to the appropriate Cloudflare Tunnel. This PR takes advantage of this flow and adds a new host_details endpoint. Calls to this endpoint will result in cloudflared gathering some details about the host: hostname (os.hostname()) and ip address (localAddr in a dial). Note that the mini spec lists 4 alternatives and this picks alternative 3 because: 1. Ease of implementation: This is quick and non-intrusive to any of our code path. We expect to change how connection tracking works and regardless of the direction we take, it may be easy to keep, morph or throw this away. 2. The cloudflared part of this round trip takes some time with a hostname call and a dial. But note that this is off the critical path and not an API that will be exercised often.
115 lines
4.3 KiB
Python
115 lines
4.3 KiB
Python
import json
|
|
import subprocess
|
|
from time import sleep
|
|
|
|
from setup import get_config_from_file
|
|
|
|
SINGLE_CASE_TIMEOUT = 600
|
|
|
|
class CloudflaredCli:
|
|
def __init__(self, config, config_path, logger):
|
|
self.basecmd = [config.cloudflared_binary, "tunnel"]
|
|
if config_path is not None:
|
|
self.basecmd += ["--config", str(config_path)]
|
|
origincert = get_config_from_file()["origincert"]
|
|
if origincert:
|
|
self.basecmd += ["--origincert", origincert]
|
|
self.logger = logger
|
|
|
|
def _run_command(self, subcmd, subcmd_name, needs_to_pass=True):
|
|
cmd = self.basecmd + subcmd
|
|
# timeout limits the time a subprocess can run. This is useful to guard against running a tunnel when
|
|
# command/args are in wrong order.
|
|
result = run_subprocess(cmd, subcmd_name, self.logger, check=needs_to_pass, capture_output=True, timeout=15)
|
|
return result
|
|
|
|
def list_tunnels(self):
|
|
cmd_args = ["list", "--output", "json"]
|
|
listed = self._run_command(cmd_args, "list")
|
|
return json.loads(listed.stdout)
|
|
|
|
def get_management_token(self, config, config_path):
|
|
basecmd = [config.cloudflared_binary]
|
|
if config_path is not None:
|
|
basecmd += ["--config", str(config_path)]
|
|
origincert = get_config_from_file()["origincert"]
|
|
if origincert:
|
|
basecmd += ["--origincert", origincert]
|
|
|
|
cmd_args = ["tail", "token", config.get_tunnel_id()]
|
|
cmd = basecmd + cmd_args
|
|
result = run_subprocess(cmd, "token", self.logger, check=True, capture_output=True, timeout=15)
|
|
return json.loads(result.stdout.decode("utf-8").strip())["token"]
|
|
|
|
def get_connector_id(self, config):
|
|
op = self.get_tunnel_info(config.get_tunnel_id())
|
|
connectors = []
|
|
for conn in op["conns"]:
|
|
connectors.append(conn["id"])
|
|
return connectors
|
|
|
|
def get_tunnel_info(self, tunnel_id):
|
|
info = self._run_command(["info", "--output", "json", tunnel_id], "info")
|
|
return json.loads(info.stdout)
|
|
|
|
def __enter__(self):
|
|
self.basecmd += ["run"]
|
|
self.process = subprocess.Popen(self.basecmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
self.logger.info(f"Run cmd {self.basecmd}")
|
|
return self.process
|
|
|
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
terminate_gracefully(self.process, self.logger, self.basecmd)
|
|
self.logger.debug(f"{self.basecmd} logs: {self.process.stderr.read()}")
|
|
|
|
|
|
def terminate_gracefully(process, logger, cmd):
|
|
process.terminate()
|
|
process_terminated = wait_for_terminate(process)
|
|
if not process_terminated:
|
|
process.kill()
|
|
logger.warning(f"{cmd}: cloudflared did not terminate within wait period. Killing process. logs: \
|
|
stdout: {process.stdout.read()}, stderr: {process.stderr.read()}")
|
|
|
|
|
|
def wait_for_terminate(opened_subprocess, attempts=10, poll_interval=1):
|
|
"""
|
|
wait_for_terminate polls the opened_subprocess every x seconds for a given number of attempts.
|
|
It returns true if the subprocess was terminated and false if it didn't.
|
|
"""
|
|
for _ in range(attempts):
|
|
if _is_process_stopped(opened_subprocess):
|
|
return True
|
|
sleep(poll_interval)
|
|
return False
|
|
|
|
|
|
def _is_process_stopped(process):
|
|
return process.poll() is not None
|
|
|
|
|
|
def cert_path():
|
|
return get_config_from_file()["origincert"]
|
|
|
|
|
|
class SubprocessError(Exception):
|
|
def __init__(self, program, exit_code, cause):
|
|
self.program = program
|
|
self.exit_code = exit_code
|
|
self.cause = cause
|
|
|
|
|
|
def run_subprocess(cmd, cmd_name, logger, timeout=SINGLE_CASE_TIMEOUT, **kargs):
|
|
kargs["timeout"] = timeout
|
|
try:
|
|
result = subprocess.run(cmd, **kargs)
|
|
logger.debug(f"{cmd} log: {result.stdout}", extra={"cmd": cmd_name})
|
|
return result
|
|
except subprocess.CalledProcessError as e:
|
|
err = f"{cmd} return exit code {e.returncode}, stderr" + e.stderr.decode("utf-8")
|
|
logger.error(err, extra={"cmd": cmd_name, "return_code": e.returncode})
|
|
raise SubprocessError(cmd[0], e.returncode, e)
|
|
except subprocess.TimeoutExpired as e:
|
|
err = f"{cmd} timeout after {e.timeout} seconds, stdout: {e.stdout}, stderr: {e.stderr}"
|
|
logger.error(err, extra={"cmd": cmd_name, "return_code": "timeout"})
|
|
raise e |