cloudflared-tunnel/setup-cloudflared.sh
2026-09-19 00:40:33 +00:00

321 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# setup-cloudflared.sh — install cloudflared on Debian 12 (bookworm) and run a
# Cloudflare Tunnel as a systemd service that forwards 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 the unprivileged 'cloudflared' user. A daily systemd timer
# (cloudflared-update.timer) upgrades cloudflared via apt and restarts the
# tunnel when a new version is installed.
#
# Re-running the script is safe; it updates the config and restarts the service.
set -euo pipefail
PORT=3000
MODE=""
HOSTNAME_FQDN=""
TUNNEL_NAME=""
TOKEN=""
CONF_DIR=/etc/cloudflared
UNIT_FILE=/etc/systemd/system/cloudflared.service
BIN=/usr/bin/cloudflared
SVC_USER=cloudflared
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"
[[ $EUID -eq 0 ]] || die "Run as root (sudo $0 ...)"
. /etc/os-release
[[ "${ID:-}" == debian ]] || warn "This script targets Debian; detected '${ID:-unknown}'. Continuing anyway."
[[ "${VERSION_CODENAME:-}" == bookworm ]] || warn "Written for Debian 12 (bookworm); detected '${VERSION_CODENAME:-unknown}'."
SERVICE_URL="http://localhost:${PORT}"
TUNNEL_NAME="${TUNNEL_NAME:-$(hostname -s)-${PORT}}"
# ------------------------------------------------------------------ install --
install_cloudflared() {
log "Installing prerequisites"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg jq >/dev/null
log "Adding Cloudflare apt repository"
install -d -m 0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg \
-o /usr/share/keyrings/cloudflare-public-v2.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-public-v2.gpg] https://pkg.cloudflare.com/cloudflared any main' \
> /etc/apt/sources.list.d/cloudflared.list
log "Installing cloudflared"
apt-get update -qq
apt-get install -y -qq cloudflared >/dev/null
log "$($BIN --version)"
}
# Unprivileged system account the daemon runs as. It only makes outbound
# connections and proxies to localhost, so it needs no root privileges.
create_service_user() {
if ! getent passwd "$SVC_USER" >/dev/null; then
log "Creating system user '$SVC_USER'"
useradd --system --user-group --no-create-home \
--home-dir /nonexistent --shell /usr/sbin/nologin "$SVC_USER"
fi
}
# ----------------------------------------------------------- systemd unit ----
# $1 = systemd service Type, $2 = cloudflared arguments, $3 = optional EnvironmentFile
write_unit() {
local type="$1" args="$2" envfile="${3:-}"
log "Writing $UNIT_FILE"
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=Cloudflare Tunnel -> ${SERVICE_URL}
After=network-online.target
Wants=network-online.target
[Service]
Type=${type}
User=${SVC_USER}
Group=${SVC_USER}
${envfile:+EnvironmentFile=${envfile}}
ExecStart=${BIN} --no-autoupdate ${args}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=120
# Sandboxing
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable cloudflared >/dev/null 2>&1
log "Starting cloudflared service"
systemctl restart cloudflared
}
# ---------------------------------------------------------------- updater ----
# cloudflared's built-in autoupdate disables itself for apt installs, and the
# unprivileged, read-only service couldn't replace its own binary anyway. So a
# root timer upgrades it through apt and restarts the tunnel only when the
# version actually changed (same idea as Cloudflare's cloudflared-update.timer).
install_updater() {
log "Installing daily auto-update timer"
cat > /usr/local/sbin/cloudflared-update <<'EOF'
#!/bin/sh
set -eu
before=$(dpkg-query -W -f='${Version}' cloudflared)
# Refresh only the cloudflared repo; leave other package lists untouched.
apt-get update -qq \
-o Dir::Etc::sourcelist=/etc/apt/sources.list.d/cloudflared.list \
-o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade \
-o DPkg::Lock::Timeout=300 cloudflared
after=$(dpkg-query -W -f='${Version}' cloudflared)
if [ "$before" != "$after" ]; then
echo "cloudflared upgraded ${before} -> ${after}; restarting tunnel"
systemctl try-restart cloudflared.service
else
echo "cloudflared ${after} is up to date"
fi
EOF
chmod 0755 /usr/local/sbin/cloudflared-update
cat > /etc/systemd/system/cloudflared-update.service <<'EOF'
[Unit]
Description=Upgrade cloudflared via apt and restart the tunnel if updated
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/cloudflared-update
EOF
cat > /etc/systemd/system/cloudflared-update.timer <<'EOF'
[Unit]
Description=Daily cloudflared update check
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now cloudflared-update.timer >/dev/null 2>&1
}
# ------------------------------------------------------------------- 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
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
write_unit notify "--config $CONF_DIR/config.yml tunnel run"
PUBLIC_URL="https://${HOSTNAME_FQDN}"
}
setup_token() {
install -d -m 0755 "$CONF_DIR"
# Keep the token out of the unit file and out of `ps` output. systemd reads
# this as root before dropping to $SVC_USER, so it can stay root-only.
(umask 077; printf 'TUNNEL_TOKEN=%s\n' "$TOKEN" > "$CONF_DIR/tunnel.env")
write_unit notify "tunnel run" "$CONF_DIR/tunnel.env"
PUBLIC_URL="(the public hostname set in the Zero Trust dashboard)"
}
setup_quick() {
write_unit simple "tunnel --url ${SERVICE_URL}"
log "Waiting for the trycloudflare.com URL"
PUBLIC_URL=""
for _ in $(seq 1 30); do
PUBLIC_URL="$(journalctl -u cloudflared --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 | grep trycloudflare)}"
}
# -------------------------------------------------------------------- main ---
install_cloudflared
create_service_user
case "$MODE" in
named) setup_named ;;
token) setup_token ;;
quick) setup_quick ;;
esac
install_updater
sleep 3
if systemctl is-active --quiet cloudflared; then
log "cloudflared is running"
else
systemctl status cloudflared --no-pager || true
die "cloudflared failed to start. See: journalctl -u cloudflared -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}
Logs: journalctl -u cloudflared -f
Restart: systemctl restart cloudflared
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