#!/usr/bin/env python3
"""
CF1 UAT - Push Recommended Gateway Policies
============================================
Interactive CLI that deploys Cloudflare One recommended DNS/HTTP/Network
policies into a chosen Cloudflare account using Global API Key auth.

Source of recommended policies:
  https://developers.cloudflare.com/learning-paths/secure-internet-traffic/

Usage:
  pip install requests
  python3 push_recommended_policies.py

Credentials:
  Uses Global API Key (not a scoped token) so Gateway:Edit permission is
  available without creating a new token. Get your key here:
  https://dash.cloudflare.com/profile/api-tokens  (section "Global API Key")
"""

import getpass
import json
import sys
import urllib3

try:
    import requests
except ImportError:
    print("ERROR: 'requests' module missing. Install with: pip install requests")
    sys.exit(1)

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

CF_API = "https://api.cloudflare.com/client/v4"
VERIFY_SSL = False   # Set True unless behind TLS inspection

# ═════════════════════════════════════════════════════════════════════════
# Recommended policy catalog (from Cloudflare One docs)
# ═════════════════════════════════════════════════════════════════════════

RECOMMENDED_POLICIES = [
    # ── DNS ──
    {
        "key": "dns-security-blocklist",
        "name": "All-DNS-SecurityCategories-Blocklist",
        "description": "Block security categories based on Cloudflare's threat intelligence (Anonymizer, C&C, Cryptomining, DGA, DNS Tunneling, Malware, Phishing, Private IP, Spam, Spyware, Brand Embedding)",
        "filter": "dns", "action": "block", "tier": 1,
        "traffic": "any(dns.security_category[*] in {68 178 80 83 176 175 117 131 134 151 153})",
    },
    {
        "key": "dns-content-blocklist",
        "name": "All-DNS-ContentCategories-Blocklist",
        "description": "Block risky content categories: Questionable Content, Security Risks, Misc. Excludes New/Newly Seen Domains (169,177) so the HTTP Isolate policy can handle them instead.",
        "filter": "dns", "action": "block", "tier": 2,
        "traffic": "any(dns.content_category[*] in {17 85 87 102 157 135 138 180 162 32 128 15 115 119 124 141 161})",
    },
    {
        "key": "dns-ai-apps-blocklist",
        "name": "All-DNS-Application-Blocklist-AI",
        "description": "Block access to unauthorized AI applications (shadow AI)",
        "filter": "dns", "action": "block", "tier": 3,
        "traffic": "any(app.type.ids[*] in {25})",
    },
    {
        "key": "dns-risky-tlds",
        "name": "All-DNS-DomainTopLevel-Blocklist",
        "description": "Block DNS queries of known risky TLDs (.zip, .mobi, .rest, .top, etc.)",
        "filter": "dns", "action": "block", "tier": 2,
        "traffic": 'any(dns.domains[*] matches "[.](rest|hair|top|live|cfd|boats|beauty|mom|skin|okinawa)$") or any(dns.domains[*] matches "[.](zip|mobi)$")',
    },

    # ── HTTP ──
    {
        "key": "http-app-inspection-bypass",
        "name": "All-HTTP-Application-InspectBypass",
        "description": "Bypass HTTP inspection for apps that use embedded certificates (certificate pinning)",
        "filter": "http", "action": "off", "tier": 1,
        "traffic": "any(app.type.ids[*] in {16})",
    },
    {
        "key": "http-security-blocklist",
        "name": "All-HTTP-SecurityRisks-Blocklist",
        "description": "Block security categories at HTTP layer (catches threats not blocked at DNS)",
        "filter": "http", "action": "block", "tier": 1,
        "traffic": "any(http.request.uri.security_category[*] in {68 178 80 83 176 175 117 131 134 151 153})",
    },
    {
        "key": "http-content-blocklist",
        "name": "All-HTTP-ContentCategories-Blocklist",
        "description": "Block questionable content and potential security risks at HTTP layer",
        "filter": "http", "action": "block", "tier": 2,
        "traffic": "any(http.request.uri.content_category[*] in {17 85 87 102 157 135 138 180 162 32 169 177 128 15 115 119 124 141 161 2 67 125 133 99})",
    },
    {
        "key": "http-ai-app-blocklist",
        "name": "All-HTTP-Application-Blocklist-AI",
        "description": "Limit access to shadow AI: block web-based AI tools at HTTP layer",
        "filter": "http", "action": "block", "tier": 3,
        "traffic": "any(app.type.ids[*] in {25})",
    },
    {
        "key": "http-isolate-new-domains",
        "name": "All-HTTP-Domain-Isolate",
        "description": "Isolate New Domains and Newly Seen Domains to avoid data exfiltration or malware",
        "filter": "http", "action": "isolate", "tier": 1,
        "traffic": "any(http.request.uri.content_category[*] in {169 177})",
    },

    # ── Network (L4) ──
    {
        "key": "net-block-quic",
        "name": "All-NET-Block-QUIC",
        "description": "Block QUIC (UDP/443) to force HTTP/HTTPS inspection by downgrading to TCP",
        "filter": "l4", "action": "block", "tier": 1,
        "traffic": 'net.protocol == "udp" and net.dst.port == 443',
    },
    {
        "key": "net-block-security-categories",
        "name": "All-NET-SecurityCategories-Blocklist",
        "description": "Block security categories at L4 layer (catches non-HTTP threats by destination FQDN)",
        "filter": "l4", "action": "block", "tier": 1,
        "traffic": "any(net.fqdn.security_category[*] in {68 178 80 83 176 175 117 131 134 151 153})",
    },
    {
        "key": "net-block-ssh-port",
        "name": "All-NET-Block-SSH-Port22",
        "description": "Block outbound SSH on the default TCP port 22 (works without Protocol Detection feature)",
        "filter": "l4", "action": "block", "tier": 3,
        "traffic": 'net.protocol == "tcp" and net.dst.port == 22',
    },
]


# ═════════════════════════════════════════════════════════════════════════
# Helpers
# ═════════════════════════════════════════════════════════════════════════

class CFClient:
    def __init__(self, email, api_key):
        self.headers = {
            "X-Auth-Email": email,
            "X-Auth-Key": api_key,
            "Content-Type": "application/json",
        }

    def get(self, path, params=None):
        r = requests.get(f"{CF_API}{path}", headers=self.headers,
                         params=params, timeout=30, verify=VERIFY_SSL)
        return r.json()

    def post(self, path, body):
        r = requests.post(f"{CF_API}{path}", headers=self.headers,
                          json=body, timeout=30, verify=VERIFY_SSL)
        return r.status_code, r.json()


def hr():
    print("─" * 72)


def color(text, code):
    return f"\033[{code}m{text}\033[0m"


RED   = lambda s: color(s, "31")
GRN   = lambda s: color(s, "32")
YEL   = lambda s: color(s, "33")
BLU   = lambda s: color(s, "34")
CYAN  = lambda s: color(s, "36")
BOLD  = lambda s: color(s, "1")
DIM   = lambda s: color(s, "2")


# ═════════════════════════════════════════════════════════════════════════
# Interactive flow
# ═════════════════════════════════════════════════════════════════════════

def prompt_credentials():
    print(BOLD("\n  CF1 UAT — Deploy Recommended Gateway Policies\n"))
    print("  Authentication uses Global API Key (not a scoped token).")
    print("  Find it at: https://dash.cloudflare.com/profile/api-tokens")
    print("  → click 'View' next to 'Global API Key'\n")

    email = input("  Cloudflare account email: ").strip()
    api_key = getpass.getpass("  Global API Key (hidden input): ").strip()

    if not email or not api_key:
        print(RED("\n  ERROR: Email and API Key are required"))
        sys.exit(1)

    return email, api_key


def verify_credentials(cf):
    print(DIM("\n  Verifying credentials..."))
    data = cf.get("/user")
    if not data.get("success"):
        print(RED("  ERROR: Invalid credentials"))
        for err in data.get("errors", []):
            print(RED(f"    - {err.get('message')}"))
        sys.exit(2)
    user = data["result"]
    print(GRN(f"  Authenticated as {user.get('email')} ({user.get('first_name', '')} {user.get('last_name', '')})"))


def choose_account(cf):
    print(DIM("\n  Fetching accounts..."))
    data = cf.get("/accounts", params={"per_page": 50})
    if not data.get("success"):
        print(RED("  ERROR: Failed to list accounts"))
        sys.exit(2)

    accounts = data.get("result", [])
    if not accounts:
        print(RED("  ERROR: No accounts accessible"))
        sys.exit(2)

    hr()
    print(BOLD("  Available accounts:"))
    for i, a in enumerate(accounts, start=1):
        print(f"    {CYAN(f'[{i}]')} {a['name']} {DIM('(' + a['id'] + ')')}")
    hr()

    while True:
        choice = input(f"\n  Choose account [1-{len(accounts)}]: ").strip()
        if choice.isdigit() and 1 <= int(choice) <= len(accounts):
            return accounts[int(choice) - 1]
        print(RED("  Invalid choice, try again"))


def choose_policies():
    by_filter = {"dns": [], "http": [], "l4": []}
    for p in RECOMMENDED_POLICIES:
        by_filter[p["filter"]].append(p)

    labels = {"dns": "DNS Policies", "http": "HTTP Policies", "l4": "Network (L4) Policies"}
    action_color = {
        "block":   lambda s: RED(s),
        "allow":   lambda s: GRN(s),
        "isolate": lambda s: BLU(s),
        "off":     lambda s: DIM(s),
    }

    tier_color = {
        1: lambda s: RED(s),
        2: lambda s: YEL(s),
        3: lambda s: CYAN(s),
    }

    hr()
    print(BOLD("  Recommended policies (from Cloudflare One docs):\n"))

    idx_map = {}
    idx = 1
    for filt in ["dns", "http", "l4"]:
        items = by_filter[filt]
        if not items:
            continue
        print(BOLD(f"  {labels[filt]}"))
        for p in items:
            idx_map[idx] = p
            action_c = action_color.get(p["action"], lambda s: s)
            tier_c = tier_color.get(p["tier"], lambda s: s)
            tier_label = tier_c(f"T{p['tier']}")
            print(f"    {CYAN(f'[{idx:2d}]')} "
                  f"{action_c(p['action'].upper().ljust(8))} "
                  f"{tier_label} "
                  f"{p['name']}")
            print(f"         {DIM(p['description'][:100])}")
            idx += 1
        print()

    hr()
    print(DIM("  Tiers: T1 = must-have | T2 = recommended | T3 = optional"))
    print()
    print("  Enter selections:")
    print(f"    • Numbers (e.g. '1,2,5-7')")
    print(f"    • 'tier1' / 'tier2' / 'tier3'")
    print(f"    • 'all' to select everything")
    print(f"    • 'dns' / 'http' / 'l4' to select by type\n")

    while True:
        sel = input("  Selection: ").strip().lower()
        if not sel:
            continue

        chosen = set()
        parts = [p.strip() for p in sel.split(",") if p.strip()]
        valid = True
        for part in parts:
            if part == "all":
                chosen.update(range(1, idx))
            elif part in ("tier1", "tier2", "tier3"):
                t = int(part[-1])
                chosen.update(i for i, p in idx_map.items() if p["tier"] == t)
            elif part in ("dns", "http", "l4"):
                chosen.update(i for i, p in idx_map.items() if p["filter"] == part)
            elif "-" in part:
                try:
                    a, b = part.split("-")
                    chosen.update(range(int(a), int(b) + 1))
                except ValueError:
                    print(RED(f"  Invalid range: {part}"))
                    valid = False
                    break
            elif part.isdigit():
                chosen.add(int(part))
            else:
                print(RED(f"  Invalid token: {part}"))
                valid = False
                break

        if not valid:
            continue

        selected = [idx_map[i] for i in sorted(chosen) if i in idx_map]
        if not selected:
            print(RED("  No valid policies selected"))
            continue

        print(f"\n  Selected {BOLD(str(len(selected)))} policies:")
        for p in selected:
            print(f"    • {p['name']}  [{p['filter']} / {p['action']}]")
        confirm = input(f"\n  Proceed? [y/N]: ").strip().lower()
        if confirm == "y":
            return selected
        else:
            print(DIM("  Reselect."))


def fetch_existing_rules(cf, account_id):
    """Return (names_set, sorted_precedences)"""
    data = cf.get(f"/accounts/{account_id}/gateway/rules")
    if not data.get("success"):
        return set(), []
    rules = data.get("result", [])
    names = {r.get("name", "") for r in rules}
    precs = sorted(r.get("precedence", 0) for r in rules if r.get("precedence", 0) > 0)
    return names, precs


def deploy_policies(cf, account_id, policies):
    print(BOLD(f"\n  Deploying {len(policies)} policies to account {account_id}"))
    print(DIM("  Rules will be inserted at the TOP of the evaluation order (lowest precedence)"))
    print(DIM("  Rules will be created DISABLED — review in dashboard then enable manually"))
    hr()

    existing_names, existing_precs = fetch_existing_rules(cf, account_id)

    # Pre-check: skip policies whose name already exists
    to_deploy = []
    skipped = []
    for p in policies:
        if p["name"] in existing_names:
            skipped.append(p)
        else:
            to_deploy.append(p)

    if skipped:
        print(DIM(f"  Skipping {len(skipped)} policies that already exist by name:"))
        for p in skipped:
            print(f"    {YEL('~')} {p['name']:50s} {DIM('(already in account — not re-created)')}")

    if not to_deploy:
        hr()
        print(YEL("  Nothing to deploy — all selected policies already exist."))
        return [{"policy": p["name"], "success": True, "skipped": True} for p in skipped]

    # Find safe precedence slots BELOW the current minimum (top of eval order)
    # Leave a big gap to avoid any collision. Descend from min-1 in unused slots.
    existing_set = set(existing_precs)
    min_existing = min(existing_precs) if existing_precs else 1_000_000
    # Start N*1000 below the minimum, walk down picking unused slots
    candidate = max(1, min_existing - (len(to_deploy) + 5) * 1000)
    next_precs = []
    for _ in to_deploy:
        while candidate in existing_set and candidate > 0:
            candidate += 1
        if candidate <= 0:
            # Fallback: let CF auto-assign (probably will place at end)
            next_precs.append(None)
        else:
            next_precs.append(candidate)
            existing_set.add(candidate)
        candidate += 1000

    results = [{"policy": p["name"], "success": True, "skipped": True, "note": "already existed"} for p in skipped]

    for p, prec in zip(to_deploy, next_precs):
        payload = {
            "name": p["name"],
            "description": p["description"],
            "action": p["action"],
            "enabled": False,
            "filters": [p["filter"]],
            "traffic": p["traffic"],
        }
        if prec is not None:
            payload["precedence"] = prec

        status, resp = cf.post(f"/accounts/{account_id}/gateway/rules", payload)
        if resp.get("success"):
            rule_id = resp["result"]["id"]
            actual_prec = resp["result"].get("precedence", "?")
            print(f"  {GRN('✓')} {p['name']:50s} {DIM(f'→ id {rule_id[:8]} @ prec {actual_prec}')}")
            results.append({"policy": p["name"], "success": True, "rule_id": rule_id, "precedence": actual_prec})
        else:
            err = (resp.get("errors") or [{}])[0].get("message", f"HTTP {status}")
            print(f"  {RED('✗')} {p['name']:50s} {RED(err[:80])}")
            results.append({"policy": p["name"], "success": False, "error": err})

    hr()
    ok = sum(1 for r in results if r.get("success") and not r.get("skipped"))
    fail = sum(1 for r in results if not r.get("success"))
    sk = sum(1 for r in results if r.get("skipped"))
    print(f"\n  Summary: {GRN(str(ok) + ' created')}, {YEL(str(sk) + ' skipped (exist)')}, {RED(str(fail) + ' failed') if fail else '0 failed'}")
    return results


def main():
    try:
        email, api_key = prompt_credentials()
        cf = CFClient(email, api_key)
        verify_credentials(cf)
        account = choose_account(cf)
        print(GRN(f"\n  Selected: {account['name']} ({account['id']})"))
        policies = choose_policies()
        results = deploy_policies(cf, account["id"], policies)

        # Save report
        import datetime
        fn = f"gateway_policy_deploy_{account['id'][:8]}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        with open(fn, "w") as f:
            json.dump({
                "account_id": account["id"],
                "account_name": account["name"],
                "deployed_by": email,
                "timestamp": datetime.datetime.now().isoformat(),
                "results": results,
            }, f, indent=2)
        print(f"\n  Report saved: {fn}\n")

        sys.exit(0 if all(r["success"] for r in results) else 1)
    except KeyboardInterrupt:
        print(RED("\n\n  Interrupted by user.\n"))
        sys.exit(130)


if __name__ == "__main__":
    main()
