#!/usr/bin/env bash
#
# cloudflared-tunnel-setup — configure the Cloudflare Tunnel that the
# cloudflared-tunnel package runs as a systemd service, forwarding to a local
# port (default 3000).
#
# Modes (pick one):
#
#   --hostname app.example.com [--name my-tunnel]
#       Locally-managed tunnel. Logs in to Cloudflare (opens a URL you visit in a
#       browser), creates the tunnel, writes /etc/cloudflared/config.yml with
#       ingress -> http://localhost:PORT, and creates the DNS CNAME.
#
#   --token <TUNNEL_TOKEN>
#       Dashboard-managed tunnel (Zero Trust > Networks > Tunnels). The ingress is
#       configured in the dashboard: set the public hostname's service to
#       http://localhost:PORT there.
#
#   --quick
#       No Cloudflare account needed. Runs a trycloudflare.com quick tunnel. The
#       URL is random and changes every time the service restarts. Testing only.
#
# Other options:
#   --port N     Local port to expose (default: 3000)
#   -h, --help   Show this help
#
# The tunnel runs as cloudflared-tunnel.service under the unprivileged
# 'cloudflared' user. The package's daily cloudflared-update.timer upgrades
# cloudflared via apt and restarts the tunnel when a new version is installed.
#
# Re-running is safe; it rewrites the config and restarts the service. It also
# takes over from the units written by the older standalone setup-cloudflared.sh.

set -euo pipefail

PORT=3000
MODE=""
HOSTNAME_FQDN=""
TUNNEL_NAME=""
TOKEN=""

CONF_DIR=/etc/cloudflared
ENV_FILE=$CONF_DIR/tunnel.env
SERVICE=cloudflared-tunnel.service
DROPIN=/etc/systemd/system/$SERVICE.d/setup.conf
BIN=/usr/bin/cloudflared
SVC_USER=cloudflared
# The package's postrm removes files carrying this line on purge.
MARKER="# Written by cloudflared-tunnel-setup; re-run it to change."

log()  { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
usage() { awk 'NR < 3 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"; exit "${1:-0}"; }

# ---------------------------------------------------------------- arguments --
while [[ $# -gt 0 ]]; do
  case "$1" in
    --hostname) HOSTNAME_FQDN="${2:?--hostname needs a value}"; MODE=named; shift 2 ;;
    --name)     TUNNEL_NAME="${2:?--name needs a value}"; shift 2 ;;
    --token)    TOKEN="${2:?--token needs a value}"; MODE=token; shift 2 ;;
    --quick)    MODE=quick; shift ;;
    --port)     PORT="${2:?--port needs a value}"; shift 2 ;;
    -h|--help)  usage 0 ;;
    *)          warn "Unknown option: $1"; usage 1 ;;
  esac
done

[[ -n "$MODE" ]] || { warn "Choose a mode: --hostname, --token, or --quick"; usage 1; }
[[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 1 && PORT <= 65535 )) || die "Invalid port: $PORT"
# Both end up in config files, so keep them to the characters they can contain.
[[ -z "$HOSTNAME_FQDN" || "$HOSTNAME_FQDN" =~ ^[A-Za-z0-9.-]+$ ]] || die "Invalid hostname: $HOSTNAME_FQDN"
[[ -z "$TOKEN" || "$TOKEN" =~ ^[A-Za-z0-9+/=_-]+$ ]] || die "That doesn't look like a tunnel token"
[[ $EUID -eq 0 ]] || die "Run as root (sudo $0 ...)"
[[ -x "$BIN" ]] && getent passwd "$SVC_USER" >/dev/null \
  || die "cloudflared or the '$SVC_USER' user is missing; (re)install the cloudflared-tunnel package"

SERVICE_URL="http://localhost:${PORT}"
TUNNEL_NAME="${TUNNEL_NAME:-$(hostname -s)-${PORT}}"

# ---------------------------------------------------------- legacy units -----
# setup-cloudflared.sh (the pre-package script) wrote its units to
# /etc/systemd/system, where they would run a second tunnel or shadow the
# packaged update timer. Recognise them by their contents and remove them.
migrate_legacy() {
  local old_unit=/etc/systemd/system/cloudflared.service
  local old_upd=/etc/systemd/system/cloudflared-update

  if [[ -f "$old_unit" ]]; then
    if grep -q "^ExecStart=$BIN --no-autoupdate" "$old_unit" && grep -q "^User=$SVC_USER\$" "$old_unit"; then
      log "Removing cloudflared.service left by setup-cloudflared.sh"
      systemctl disable --now cloudflared.service >/dev/null 2>&1 || true
      rm -f "$old_unit"
    else
      warn "$old_unit also runs a tunnel (from 'cloudflared service install'?). Remove it if it's the same one."
    fi
  fi

  if [[ -f "$old_upd.service" ]] && grep -q '^ExecStart=/usr/local/sbin/cloudflared-update$' "$old_upd.service"; then
    log "Replacing the update timer left by setup-cloudflared.sh"
    systemctl disable --now cloudflared-update.timer >/dev/null 2>&1 || true
    rm -f "$old_upd.service" "$old_upd.timer" /usr/local/sbin/cloudflared-update
    systemctl daemon-reload
    systemctl enable --now cloudflared-update.timer >/dev/null 2>&1
  fi
}

# ---------------------------------------------------------------- service ----
# The packaged unit runs `cloudflared --no-autoupdate $CLOUDFLARED_ARGS` from
# $ENV_FILE; the drop-in carries the per-mode service Type.
# $1 = systemd service Type, $2 = cloudflared arguments, $3 = optional token
configure_service() {
  local type="$1" args="$2" token="${3:-}"
  install -d -m 0755 "$CONF_DIR" "$(dirname "$DROPIN")"

  log "Writing $ENV_FILE"
  # Root-only, since it may hold the token. systemd reads it as root before
  # dropping to $SVC_USER, and it keeps the token out of `ps` output.
  (
    umask 077
    {
      echo "$MARKER"
      echo "CLOUDFLARED_ARGS=$args"
      if [[ -n "$token" ]]; then echo "TUNNEL_TOKEN=$token"; fi
    } > "$ENV_FILE"
  )

  cat > "$DROPIN" <<EOF
$MARKER
[Unit]
Description=Cloudflare Tunnel -> ${SERVICE_URL}

[Service]
Type=${type}
EOF

  migrate_legacy
  systemctl daemon-reload
  systemctl enable "$SERVICE" >/dev/null 2>&1
  log "Starting $SERVICE"
  systemctl restart "$SERVICE"
}

# ------------------------------------------------------------------- modes ---
setup_named() {
  local cert=/root/.cloudflared/cert.pem uuid creds

  if [[ ! -f "$cert" ]]; then
    log "Logging in to Cloudflare. Open the URL below in a browser and pick the zone for ${HOSTNAME_FQDN}."
    $BIN tunnel login
  else
    log "Using existing Cloudflare login ($cert)"
  fi

  uuid="$($BIN tunnel list --name "$TUNNEL_NAME" --output json 2>/dev/null | jq -r '.[0].id // empty')"
  if [[ -z "$uuid" ]]; then
    log "Creating tunnel '$TUNNEL_NAME'"
    $BIN tunnel create "$TUNNEL_NAME"
    uuid="$($BIN tunnel list --name "$TUNNEL_NAME" --output json | jq -r '.[0].id // empty')"
    [[ -n "$uuid" ]] || die "Tunnel was created, but its ID could not be found"
  else
    log "Tunnel '$TUNNEL_NAME' already exists ($uuid)"
  fi

  install -d -m 0755 "$CONF_DIR"
  creds="$CONF_DIR/${uuid}.json"
  if [[ ! -f "$creds" ]]; then
    if [[ -f "/root/.cloudflared/${uuid}.json" ]]; then
      install -m 0600 "/root/.cloudflared/${uuid}.json" "$creds"
    else
      log "Fetching credentials for existing tunnel"
      $BIN tunnel token --cred-file "$creds" "$uuid"
    fi
  fi
  # Only the service user may read the tunnel secret. cert.pem stays in
  # /root/.cloudflared, so the daemon cannot create or delete tunnels.
  chown "$SVC_USER:$SVC_USER" "$creds"
  chmod 0600 "$creds"

  log "Writing $CONF_DIR/config.yml (${HOSTNAME_FQDN} -> ${SERVICE_URL})"
  cat > "$CONF_DIR/config.yml" <<EOF
$MARKER
tunnel: ${uuid}
credentials-file: ${creds}

ingress:
  - hostname: ${HOSTNAME_FQDN}
    service: ${SERVICE_URL}
  - service: http_status:404
EOF
  $BIN tunnel --config "$CONF_DIR/config.yml" ingress validate

  log "Routing DNS: ${HOSTNAME_FQDN} -> tunnel ${TUNNEL_NAME}"
  if ! $BIN tunnel route dns "$uuid" "$HOSTNAME_FQDN"; then
    warn "DNS route failed. If a record for ${HOSTNAME_FQDN} already exists, delete it in the"
    warn "Cloudflare dashboard or re-run: cloudflared tunnel route dns --overwrite-dns $uuid $HOSTNAME_FQDN"
  fi

  configure_service notify "--config $CONF_DIR/config.yml tunnel run"
  PUBLIC_URL="https://${HOSTNAME_FQDN}"
}

setup_token() {
  configure_service notify "tunnel run" "$TOKEN"
  PUBLIC_URL="(the public hostname set in the Zero Trust dashboard)"
}

setup_quick() {
  configure_service simple "tunnel --url ${SERVICE_URL}"
  log "Waiting for the trycloudflare.com URL"
  PUBLIC_URL=""
  for _ in $(seq 1 30); do
    PUBLIC_URL="$(journalctl -u "$SERVICE" --since '-2min' --no-pager -o cat 2>/dev/null \
      | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -n1 || true)"
    [[ -n "$PUBLIC_URL" ]] && break
    sleep 1
  done
  PUBLIC_URL="${PUBLIC_URL:-(not found yet; check: journalctl -u cloudflared-tunnel | grep trycloudflare)}"
}

# -------------------------------------------------------------------- main ---
case "$MODE" in
  named) setup_named ;;
  token) setup_token ;;
  quick) setup_quick ;;
esac

sleep 3
if systemctl is-active --quiet "$SERVICE"; then
  log "cloudflared is running"
else
  systemctl status "$SERVICE" --no-pager || true
  die "cloudflared failed to start. See: journalctl -u cloudflared-tunnel -e"
fi

if ! ss -ltnH "sport = :${PORT}" | grep -q .; then
  warn "Nothing is listening on port ${PORT} yet. Visitors will get a 502 until your app is running."
fi

cat <<EOF

------------------------------------------------------------------
 Tunnel:   ${PUBLIC_URL}
 Origin:   ${SERVICE_URL}
 Runs as:  ${SVC_USER} (${SERVICE})
 Logs:     journalctl -u cloudflared-tunnel -f
 Restart:  systemctl restart cloudflared-tunnel
 Updates:  daily (systemctl list-timers cloudflared-update.timer)
           run now: systemctl start cloudflared-update
           history: journalctl -u cloudflared-update
------------------------------------------------------------------
EOF

if [[ "$MODE" == token ]]; then
  cat <<EOF
 In the Zero Trust dashboard (Networks > Tunnels > your tunnel >
 Public Hostname), set the service to:  ${SERVICE_URL}
------------------------------------------------------------------
EOF
fi
