# pylint: disable=unused-import
"""
Module for Setting up CloudFlare Zero Trust Tunnels
:depends:
CloudFlare python module
This module requires the python wrapper for the CloudFlare API.
https://github.com/cloudflare/python-cloudflare
Cloudflared Tunnel Client
This module requires that the cloudflared utility to be installed.
https://github.com/cloudflare/cloudflared
:configuration: This module can be used by specifying the name of a
configuration profile in the minion config, minion pillar, or master
config. The module will use the 'cloudflare' key by default
For example:
.. code-block:: yaml
cloudflare:
api_token:
account:
api_token:
API Token with permissions to create CloudFlare Tunnels
account:
CloudFlare Account ID, this can be found on the bottom right of the Overview page for your
domain
"""
import logging
import salt.exceptions
import salt.utils
import saltext.cloudflare_tunnel.utils.cloudflare_tunnel_mod as cf_tunnel_utils
try:
import CloudFlare
HAS_LIBS = True
except ImportError:
HAS_LIBS = False
log = logging.getLogger(__name__)
__virtualname__ = "cloudflare_tunnel"
def __virtual__():
# Check to make sure the python wrapper for CloudFlare and the CloudFlare CLI are installed
if not salt.utils.path.which("cloudflared"):
return (
False,
"The cloudflare execution module cannot be loaded: "
+ "cloudflared cli is not installed",
)
if HAS_LIBS:
return __virtualname__
return (
False,
"The cloudflare execution module cannot be loaded: "
"Cloudflare Python module is not installed.",
)
def _simple_tunnel(tunnel):
"""
Simplify the results returned from the API
"""
return {
"status": tunnel["status"],
"id": tunnel["id"],
"name": tunnel["name"],
"account_tag": tunnel["account_tag"],
}
def _simple_zone(zone):
"""
Simplify the results returned from the API
"""
return {"id": zone["id"], "name": zone["name"], "status": zone["status"]}
def _simple_dns(dns):
"""
Simplify the results returned from the API
"""
return {
"id": dns["id"],
"name": dns["name"],
"type": dns["type"],
"content": dns["content"],
"proxied": dns["proxied"],
"zone_id": dns["zone_id"],
"comment": dns["comment"],
}
def _simple_config(tunnel_config):
"""
Simplify the results returned from the API
"""
return {"tunnel_id": tunnel_config["tunnel_id"], "config": tunnel_config["config"]}
def _get_tunnel_token(tunnel_id):
"""
Generates a tunnel token to be used when installing the cloudflared connector
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
account = __salt__["config.get"]("cloudflare").get("account")
return cf_tunnel_utils.get_tunnel_token(api_token, account, tunnel_id)
def _get_zone_id(domain_name):
"""
Gets the Zone ID from supplied domain name.
Zone ID is used in the majority of cloudflare api calls. It is the unique ID
for each domain that is hosted
domain_name
Domain name of the zone_id you want to get
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
zone_details = None
# Split the dns name to pull out just the domain name to grab the zone id
domain_split = domain_name.split(".")
domain_length = len(domain_split)
domain = f"{domain_split[domain_length - 2]}.{domain_split[domain_length - 1]}"
zone_details = cf_tunnel_utils.get_zone_id(api_token, domain)
if zone_details:
ret_zone_details = {}
ret_zone_details = _simple_zone(zone_details[0])
else:
return False
return ret_zone_details
[docs]
def get_tunnel(tunnel_name):
"""
Get tunnel details for the supplied name
tunnel_name
User friendly name of the tunnel
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.get_tunnel sample-tunnel
Returns a dictionary containing the tunnel details if successful or ``False`` if tunnel doesn't
exist
"""
account = __salt__["config.get"]("cloudflare").get("account")
api_token = __salt__["config.get"]("cloudflare").get("api_token")
tunnel = cf_tunnel_utils.get_tunnel(api_token, account, tunnel_name)
if not tunnel:
return False
return _simple_tunnel(tunnel[0])
[docs]
def create_tunnel(tunnel_name):
"""
Create a cloudflare configured cloudflare tunnel
tunnel_name
User friendly name for the tunnel
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.create_tunnel example_tunnel_name
Returns a dictionary containing the tunnel details if successful or ``False`` if it already
exists
"""
account = __salt__["config.get"]("cloudflare").get("account")
api_token = __salt__["config.get"]("cloudflare").get("api_token")
tunnel = get_tunnel(tunnel_name)
if tunnel:
return (False, f"Tunnel {tunnel_name} already exists")
else:
tunnel = cf_tunnel_utils.create_tunnel(api_token, account, tunnel_name)
return _simple_tunnel(tunnel)
[docs]
def remove_tunnel(tunnel_id):
"""
Delete a cloudflare tunnel
tunnel_id
tunnel uuid to remove
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.remove_tunnel <tunnel uuid>
Returns ``True`` if tunnel removed
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
account = __salt__["config.get"]("cloudflare").get("account")
tunnel = cf_tunnel_utils.remove_tunnel(api_token, account, tunnel_id)
if tunnel:
return True
else:
raise salt.exceptions.ArgumentValueError(f"Unable to find tunnel with id {tunnel_id}")
[docs]
def get_dns(dns_name):
"""
Get DNS details for the supplied dns name
dns_name
DNS record name
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.get_dns sample.example.com
Returns a dictionary containing the dns details or ``False`` if it does not exist
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
zone = _get_zone_id(dns_name)
if zone:
dns = cf_tunnel_utils.get_dns(api_token, zone["id"], dns_name)
if dns:
dns_details = {}
dns_details = dns[0]
else:
return False
else:
raise salt.exceptions.ArgumentValueError(f"Zone not found for dns {dns_name}")
return _simple_dns(dns_details)
[docs]
def create_dns(hostname, tunnel_id):
"""
Create cname record for the tunnel
hostname
DNS record name
tunnel_id
tunnel uuid
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.create_dns test.example.com tunnel_id
Returns a dictionary containing the dns details
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
zone = _get_zone_id(hostname)
if zone:
# Split the dns name to pull out just the domain name to grab the zone id
domain_split = hostname.split(".")
dns = get_dns(hostname)
dns_data = {
"name": domain_split[0],
"type": "CNAME",
"content": f"{tunnel_id}.cfargotunnel.com",
"ttl": 1,
"proxied": True,
"comment": "DNS managed by SaltStack",
}
if dns:
# If DNS exist, check to see if it is pointing to the correct tunnel
if dns["content"] != f"{tunnel_id}.cfargotunnel.com":
dns = cf_tunnel_utils.create_dns(api_token, zone["id"], dns_data, dns["id"])
else:
dns = cf_tunnel_utils.create_dns(api_token, zone["id"], dns_data)
else:
raise salt.exceptions.ArgumentValueError(
f"Cloudflare zone not found for hostname {hostname}"
)
return _simple_dns(dns)
[docs]
def remove_dns(hostname):
"""
Delete a cloudflare dns entry
hostname
DNS record to remove
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.remove_dns sub.domain.com
Returns ``True`` if successful
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
dns = get_dns(hostname)
if dns:
ret_dns = cf_tunnel_utils.remove_dns(api_token, dns["zone_id"], dns["id"])
if ret_dns:
return True
else:
raise salt.exceptions.CommandExecutionError("Issue removing DNS entry")
else:
raise salt.exceptions.ArgumentValueError(f"Could not find DNS entry for {hostname}")
[docs]
def get_tunnel_config(tunnel_id):
"""
Get a cloudflare tunnel configuration
tunnel_id
tunnel uuid to get the config of
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.get_tunnel_config <tunnel uuid>
Returns a dictionary containing the tunnel configuration details or ``False`` if it does not
exist
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
account = __salt__["config.get"]("cloudflare").get("account")
tunnel_config = cf_tunnel_utils.get_tunnel_config(api_token, account, tunnel_id)
if tunnel_config["config"] is None:
return False
return _simple_config(tunnel_config)
[docs]
def create_tunnel_config(tunnel_id, config):
"""
Create a cloudflare tunnel configuration
See https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup
/tunnel-guide/local/local-management/configuration-file/ for config options
Automatically adds the catch-all rule http_status:404
tunnel_id
tunnel uuid to add the config to
config
ingress rules for the tunnel
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.create_tunnel_config <tunnel uuid> \
'{ingress : [{hostname": "test", "service": "https://localhost:8000" }]}'
Returns a dictionary containing the tunnel configuration details
"""
api_token = __salt__["config.get"]("cloudflare").get("api_token")
account = __salt__["config.get"]("cloudflare").get("account")
if {"service": "http_status:404"} not in config["ingress"]:
config["ingress"].append({"service": "http_status:404"})
tunnel_config = cf_tunnel_utils.create_tunnel_config(
api_token, account, tunnel_id, {"config": config}
)
if not tunnel_config:
raise salt.exceptions.CommandExecutionError("There was an issue creating the tunnel config")
return _simple_config(tunnel_config)
[docs]
def is_connector_installed():
"""
Check if connector service installed
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.is_connector_installed
Returns ``True`` if it installed or ``False`` if it is not
"""
return __salt__["service.available"]("cloudflared")
[docs]
def install_connector(tunnel_id):
"""
Install the connector service
tunnel_id
tunnel uuid to connect cloudflared to
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.install_connector <tunnel uuid>
Returns ``True`` if successful
"""
token = _get_tunnel_token(tunnel_id)
output = __salt__["cmd.run"](f"cloudflared service install {token}")
if "installed successfully" not in output:
raise salt.exceptions.CommandExecutionError("Error installing connector")
return True
[docs]
def remove_connector():
"""
Remove the connector service
CLI Example:
.. code-block:: bash
salt '*' cloudflare_tunnel.remove_connector
Returns ``True`` if successful
"""
# Check to see if connector is installed as a service before removing.
if __salt__["service.available"]("cloudflared"):
output = __salt__["cmd.run"]("cloudflared service uninstall")
if "uninstalled successfully" not in output:
raise salt.exceptions.CommandExecutionError("Error uninstalling connector")
return True