321 lines
11 KiB
Bash
Executable File
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
|