Initial Commit

This commit is contained in:
root 2026-09-19 00:40:33 +00:00
commit d8e5f2796e
16 changed files with 1104 additions and 0 deletions

2
cloudflared-tunnel/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
publish.env

View File

@ -0,0 +1,14 @@
Package: cloudflared-tunnel
Version: @VERSION@
Architecture: all
Maintainer: Mike Mainguy <mike.mainguy@gmail.com>
Section: net
Priority: optional
Depends: cloudflared, adduser, ca-certificates, iproute2, jq, systemd
Description: Cloudflare Tunnel to a local port, run as a systemd service
Provides cloudflared-tunnel-setup, which configures a named, dashboard-token
or quick (trycloudflare.com) Cloudflare Tunnel forwarding to
http://localhost:PORT and runs it as the unprivileged 'cloudflared' user.
.
A daily systemd timer upgrades cloudflared via apt and restarts the tunnel
when a new version is installed.

View File

@ -0,0 +1,54 @@
#!/bin/sh
set -e
if [ "$1" = configure ]; then
# Unprivileged system account the daemon runs as. It only makes outbound
# connections and proxies to localhost, so it needs no root privileges.
if ! getent passwd cloudflared >/dev/null; then
adduser --system --group --no-create-home --home /nonexistent --quiet cloudflared
fi
fi
if [ "$1" = configure ] || [ "$1" = abort-upgrade ] || [ "$1" = abort-deconfigure ] || [ "$1" = abort-remove ]; then
# Update timer: enabled on first install, and an admin's later `systemctl
# disable` survives upgrades (the same logic dh_installsystemd generates).
deb-systemd-helper unmask cloudflared-update.timer >/dev/null || true
if deb-systemd-helper --quiet was-enabled cloudflared-update.timer; then
deb-systemd-helper enable cloudflared-update.timer >/dev/null || true
else
deb-systemd-helper update-state cloudflared-update.timer >/dev/null || true
fi
if [ -d /run/systemd/system ]; then
systemctl --system daemon-reload >/dev/null || true
deb-systemd-invoke start cloudflared-update.timer >/dev/null || true
# cloudflared-tunnel-setup enables the tunnel, not the package. On upgrade,
# restart it if it's running so a changed unit file takes effect.
systemctl try-restart cloudflared-tunnel.service >/dev/null || true
fi
fi
# First install only ($2 is the previously-configured version on upgrade). The
# package ships no tunnel config, so tell the admin how to register one.
if [ "$1" = configure ] && [ -z "$2" ]; then
cat <<'EOF'
cloudflared-tunnel is installed, but no tunnel is configured yet. Pick a mode:
# Named tunnel on your own domain (prints a URL to log in to Cloudflare):
sudo cloudflared-tunnel-setup --hostname app.example.com --port 3000
# Dashboard-managed tunnel (Zero Trust > Networks > Tunnels; copy the token,
# then set that hostname's service to http://localhost:PORT in the dashboard):
sudo cloudflared-tunnel-setup --token <TUNNEL_TOKEN> --port 3000
# Random trycloudflare.com URL, no Cloudflare account needed (testing only):
sudo cloudflared-tunnel-setup --quick --port 3000
cloudflared-tunnel.service stays stopped until you run one of the above.
Details: cloudflared-tunnel-setup --help, and
/usr/share/doc/cloudflared-tunnel/README.Debian
EOF
fi
exit 0

View File

@ -0,0 +1,41 @@
#!/bin/sh
set -e
MARKER='Written by cloudflared-tunnel-setup'
case "$1" in
remove)
deb-systemd-helper mask cloudflared-update.timer >/dev/null || true
;;
purge)
deb-systemd-helper purge cloudflared-update.timer >/dev/null || true
deb-systemd-helper unmask cloudflared-update.timer >/dev/null || true
# Delete what cloudflared-tunnel-setup wrote (it marks its files), including
# the tunnel secret. Tunnels and DNS records in your Cloudflare account, and
# the login in /root/.cloudflared, are left alone.
conf=/etc/cloudflared/config.yml
if [ -f "$conf" ] && grep -q "$MARKER" "$conf"; then
creds=$(sed -n 's/^credentials-file: *//p' "$conf")
case "$creds" in
/etc/cloudflared/*.json) rm -f "$creds" ;;
esac
rm -f "$conf"
fi
if [ -f /etc/cloudflared/tunnel.env ] && grep -q "$MARKER" /etc/cloudflared/tunnel.env; then
rm -f /etc/cloudflared/tunnel.env
fi
rmdir /etc/cloudflared 2>/dev/null || true
rm -f /etc/systemd/system/cloudflared-tunnel.service.d/setup.conf
rmdir /etc/systemd/system/cloudflared-tunnel.service.d 2>/dev/null || true
rm -f /etc/systemd/system/multi-user.target.wants/cloudflared-tunnel.service
;;
esac
if [ -d /run/systemd/system ]; then
systemctl --system daemon-reload >/dev/null || true
fi
exit 0

View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
deb-systemd-invoke stop cloudflared-update.timer cloudflared-tunnel.service >/dev/null || true
fi
exit 0

View File

@ -0,0 +1,124 @@
# cloudflared-tunnel
A Debian package that runs a Cloudflare Tunnel to a local port as a systemd
service (the packaged form of `../setup-cloudflared.sh`), published to a Gitea
Debian package registry.
| Path | Installed as |
|---|---|
| `src/cloudflared-tunnel-setup` | `/usr/sbin/cloudflared-tunnel-setup`: run once per machine to pick a mode |
| `src/cloudflared-update` | `/usr/sbin/cloudflared-update`: daily cloudflared upgrade |
| `src/*.service`, `src/*.timer` | `/usr/lib/systemd/system/` |
| `src/README.Debian` | `/usr/share/doc/cloudflared-tunnel/README.Debian`: admin notes, shown as a pointer by `postinst` |
| `DEBIAN/` | package metadata and maintainer scripts |
| `build.sh` | builds `dist/cloudflared-tunnel_<VERSION>_all.deb` with plain `dpkg-deb` |
| `publish.sh` | uploads to Gitea, and mirrors cloudflared with `--cloudflared` |
## Publishing to Gitea
### 1. Create a Gitea token (one time)
In Gitea, go to **Settings → Applications → Generate New Token** and give it
**package: Read and Write** scope.
### 2. Create `publish.env` (one time)
Put it next to the scripts. It's git-ignored.
```bash
GITEA_URL=https://git.dasfad.com
GITEA_OWNER=mmainguy # user or org that owns the packages
GITEA_USER=mmainguy # account the token belongs to
GITEA_TOKEN=xxxxxxxx
```
Optional: `DIST` (default `bookworm`), `COMPONENT` (default `main`) and
`CLOUDFLARED_ARCHES` (default `"amd64 arm64"`).
### 3. Publish
```bash
./build.sh # -> dist/cloudflared-tunnel_<VERSION>_all.deb
./publish.sh # uploads it
./publish.sh --cloudflared # mirrors the latest cloudflared (amd64 + arm64)
```
Mirroring cloudflared means your machines only need your Gitea repo. The
cloudflared package is about 20 MB, so if Gitea is behind nginx, raise
`client_max_body_size` (e.g. `100m`) first.
Keep the mirror current, or the machines' daily updater has nothing new to
install. Re-uploading a version that's already there is just skipped, so this
is safe to run daily from cron on the machine that holds `publish.env`:
```
30 5 * * * /path/to/cloudflared-tunnel/publish.sh --cloudflared >/dev/null
```
To check the upload, look at the owner's **Packages** tab in Gitea, or run
`curl https://git.dasfad.com/api/packages/mmainguy/debian/dists/bookworm/Release`.
### 4. Install on each machine
```bash
sudo curl -fsSL https://git.dasfad.com/api/packages/mmainguy/debian/repository.key \
-o /etc/apt/keyrings/gitea-mmainguy.asc
echo 'deb [signed-by=/etc/apt/keyrings/gitea-mmainguy.asc] https://git.dasfad.com/api/packages/mmainguy/debian bookworm main' \
| sudo tee /etc/apt/sources.list.d/gitea-mmainguy.list
sudo apt update && sudo apt install cloudflared-tunnel
sudo cloudflared-tunnel-setup --hostname app.example.com --port 3000
# or: --token <TUNNEL_TOKEN> or: --quick
```
Installing the package doesn't start a tunnel. `cloudflared-tunnel-setup` does
that, and it's safe to re-run to change the mode or port.
If the Gitea owner is private, apt needs credentials. Keep them out of the
world-readable sources file:
```bash
printf 'machine git.dasfad.com\nlogin mmainguy\npassword <read-only token>\n' \
| sudo install -m 0600 /dev/stdin /etc/apt/auth.conf.d/gitea.conf
```
### 5. Release an update
```bash
echo 1.0.1 > VERSION # must change every release; Gitea won't overwrite a version
./build.sh && ./publish.sh
```
Machines pick it up with `apt upgrade`. If the tunnel is running, the upgrade
restarts it.
## Changes from `setup-cloudflared.sh`
- **The tunnel service is now `cloudflared-tunnel.service`.** This avoids a
clash with the `cloudflared.service` that `cloudflared service install`
writes. Logs are under `journalctl -u cloudflared-tunnel`.
- **Old setups are cleaned up automatically.** On machines set up with
`setup-cloudflared.sh`, install the package and re-run
`cloudflared-tunnel-setup` with the same options. It removes the old
`/etc/systemd/system/cloudflared*` units and `/usr/local/sbin/cloudflared-update`.
It identifies them by their contents and does this just before starting the
new service, so a failed login doesn't leave you with no tunnel. If you mirror
cloudflared, also delete `/etc/apt/sources.list.d/cloudflared.list`.
- **The daily updater refreshes all apt sources**, not just `cloudflared.list`,
so it works with the Gitea mirror.
- **`apt purge cloudflared-tunnel` deletes what the setup wrote**, including the
tunnel secret. It leaves your Cloudflare tunnels, DNS records and the
`/root/.cloudflared` login alone.
- **Maintainer** in `DEBIAN/control` is set to Mike Mainguy; change it if needed.
- `setup-cloudflared.sh` is unchanged and still works on its own.
## Day to day
```bash
journalctl -u cloudflared-tunnel -f # tunnel logs
sudo systemctl restart cloudflared-tunnel # restart the tunnel
systemctl list-timers cloudflared-update.timer # next update check
sudo systemctl start cloudflared-update # check for a cloudflared update now
journalctl -u cloudflared-update # update history
sudo apt purge cloudflared-tunnel # remove the service and the files the setup wrote
```

View File

@ -0,0 +1 @@
0.0.2

35
cloudflared-tunnel/build.sh Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# build.sh — build dist/cloudflared-tunnel_<VERSION>_all.deb with plain dpkg-deb
# (no debhelper needed). Bump VERSION before every release: Gitea refuses to
# overwrite a version it already has.
set -euo pipefail
cd "$(dirname "$0")"
VERSION="$(tr -d '[:space:]' < VERSION)"
[[ "$VERSION" =~ ^[0-9][A-Za-z0-9.+~-]*$ ]] || { echo "Bad VERSION: '$VERSION'" >&2; exit 1; }
OUT="dist/cloudflared-tunnel_${VERSION}_all.deb"
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
chmod 0755 "$stage" # becomes the package's ./ entry; mktemp makes it 0700
install -D -m 0755 src/cloudflared-tunnel-setup "$stage/usr/sbin/cloudflared-tunnel-setup"
install -D -m 0755 src/cloudflared-update "$stage/usr/sbin/cloudflared-update"
install -D -m 0644 src/README.Debian \
"$stage/usr/share/doc/cloudflared-tunnel/README.Debian"
install -d "$stage/usr/lib/systemd/system"
install -m 0644 src/cloudflared-tunnel.service src/cloudflared-update.service \
src/cloudflared-update.timer "$stage/usr/lib/systemd/system/"
install -d "$stage/DEBIAN"
install -m 0755 DEBIAN/postinst DEBIAN/prerm DEBIAN/postrm "$stage/DEBIAN/"
{
sed "s/@VERSION@/${VERSION}/" DEBIAN/control
echo "Installed-Size: $(du -sk --exclude=DEBIAN "$stage" | cut -f1)"
} > "$stage/DEBIAN/control"
mkdir -p dist
dpkg-deb --root-owner-group -Zxz --build "$stage" "$OUT" >/dev/null
echo "Built $OUT"

87
cloudflared-tunnel/publish.sh Executable file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# publish.sh — upload .deb files to a Gitea (or Forgejo) Debian package registry.
#
# ./publish.sh upload dist/cloudflared-tunnel_<VERSION>_all.deb
# ./publish.sh FILE.deb ... upload these files
# ./publish.sh --cloudflared mirror the latest cloudflared release into the
# registry, so clients need only your repo
#
# Settings come from the environment (or a ./publish.env file, which is sourced
# if present):
# GITEA_URL https://git.dasfad.com
# GITEA_OWNER user or org that owns the packages (e.g. mmainguy)
# GITEA_USER account to upload as (default: GITEA_OWNER)
# GITEA_TOKEN personal access token with package read/write scope
# DIST distribution (default: bookworm)
# COMPONENT component (default: main)
# CLOUDFLARED_ARCHES architectures to mirror (default: "amd64 arm64")
#
# Uploading a version that is already in the registry is reported and skipped,
# so --cloudflared is safe to run from cron.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f publish.env ]] && . ./publish.env
: "${GITEA_URL:?set GITEA_URL (e.g. https://git.dasfad.com)}"
: "${GITEA_OWNER:?set GITEA_OWNER}"
: "${GITEA_TOKEN:?set GITEA_TOKEN}"
GITEA_USER="${GITEA_USER:-$GITEA_OWNER}"
DIST="${DIST:-bookworm}"
COMPONENT="${COMPONENT:-main}"
CLOUDFLARED_ARCHES="${CLOUDFLARED_ARCHES:-amd64 arm64}"
REPO="${GITEA_URL%/}/api/packages/${GITEA_OWNER}/debian"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
# The credentials go to curl on stdin (-K -), so the token never shows in `ps`.
upload() {
local deb="$1" body code
body="$(mktemp)"
code="$(printf 'user = "%s:%s"\n' "$GITEA_USER" "$GITEA_TOKEN" \
| curl -sS -K - -o "$body" -w '%{http_code}' --upload-file "$deb" \
"$REPO/pool/$DIST/$COMPONENT/upload")" || { rm -f "$body"; die "Upload of $deb failed"; }
case "$code" in
201) log "Published $(basename "$deb") to $DIST/$COMPONENT" ;;
409) log "$(basename "$deb") is already in $DIST/$COMPONENT; skipped" ;;
*) { cat "$body"; echo; } >&2; rm -f "$body"; die "Upload of $deb failed (HTTP $code)" ;;
esac
rm -f "$body"
}
mirror_cloudflared() {
local arch
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
for arch in $CLOUDFLARED_ARCHES; do
log "Downloading latest cloudflared ($arch)"
curl -fsSL -o "$tmp/cloudflared-$arch.deb" \
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb"
log "cloudflared $(dpkg-deb -f "$tmp/cloudflared-$arch.deb" Version) ($arch)"
upload "$tmp/cloudflared-$arch.deb"
done
}
files=()
mirror=no
for arg in "$@"; do
case "$arg" in
--cloudflared) mirror=yes ;;
-h|--help) awk 'NR < 3 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"; exit 0 ;;
-*) die "Unknown option: $arg" ;;
*) files+=("$arg") ;;
esac
done
if [[ ${#files[@]} -eq 0 && $mirror == no ]]; then
files=("dist/cloudflared-tunnel_$(tr -d '[:space:]' < VERSION)_all.deb")
fi
for f in "${files[@]}"; do
[[ -f "$f" ]] || die "$f not found (run ./build.sh first?)"
upload "$f"
done
[[ $mirror == yes ]] && mirror_cloudflared
log "Repository: $REPO $DIST $COMPONENT"

View File

@ -0,0 +1,92 @@
cloudflared-tunnel for Debian
=============================
Installing this package does not start a tunnel. It installs the tunnel
service, the daily cloudflared updater, and the setup command; you register a
tunnel by running cloudflared-tunnel-setup once per machine.
Registering a tunnel
--------------------
Pick one of three modes. All of them take --port (default 3000), which is the
local port the tunnel forwards to (http://localhost:PORT).
1. Named tunnel on a domain in your Cloudflare account:
sudo cloudflared-tunnel-setup --hostname app.example.com --port 3000
This prints a URL to open in a browser to authorize cloudflared against the
zone, then creates the tunnel, writes /etc/cloudflared/config.yml with an
ingress rule to http://localhost:PORT, and creates the DNS CNAME. Add
--name my-tunnel to override the generated tunnel name (<short-hostname>-<port>).
The browser login is stored in /root/.cloudflared/cert.pem and is reused by
later runs, so subsequent tunnels on this machine need no second login.
2. Dashboard-managed tunnel. Create the tunnel under Zero Trust > Networks >
Tunnels, copy its token, and run:
sudo cloudflared-tunnel-setup --token <TUNNEL_TOKEN> --port 3000
The ingress lives in the dashboard, not on disk: set the public hostname's
service to http://localhost:PORT there, or the tunnel will connect and serve
nothing. --port only affects what this machine expects to proxy to.
3. Quick tunnel, no Cloudflare account needed:
sudo cloudflared-tunnel-setup --quick --port 3000
Serves a random trycloudflare.com URL, which changes on every restart of the
service. Read it out of the log (journalctl -u cloudflared-tunnel). Testing
only; do not use it for anything durable.
Re-running the setup is safe. It rewrites the configuration and restarts the
service, so it is also how you change mode or port later.
What ends up where
------------------
/etc/cloudflared/config.yml named mode: ingress and credentials-file
/etc/cloudflared/*.json named mode: the tunnel's credentials (secret)
/etc/cloudflared/tunnel.env token/quick mode: the token, mode and port
/etc/systemd/system/cloudflared-tunnel.service.d/setup.conf
the ExecStart the chosen mode needs
Files the setup writes carry a marker comment; the package's postrm deletes
exactly those on purge (including the tunnel credentials), and leaves your
Cloudflare tunnels, DNS records and /root/.cloudflared login untouched.
The service runs as the unprivileged system user 'cloudflared', created on
install. It only makes outbound connections and proxies to localhost, so it
needs no privileges of its own -- but that also means the port you expose must
be reachable from that user's network namespace, i.e. a normal localhost
listener. A service bound to 127.0.0.1 is fine; one inside a container's own
netns is not.
Updates
-------
cloudflared-update.timer runs daily, refreshes apt, upgrades cloudflared if a
new version is available, and restarts cloudflared-tunnel.service only when
the binary actually changed.
systemctl list-timers cloudflared-update.timer next scheduled check
sudo systemctl start cloudflared-update check right now
journalctl -u cloudflared-update what it has done
sudo systemctl disable cloudflared-update.timer opt out (survives upgrades)
The timer needs cloudflared to be installable from an apt source the machine
can reach -- either Cloudflare's own repository or a mirror in your package
registry. Without one there is nothing for it to install.
Troubleshooting
---------------
journalctl -u cloudflared-tunnel -f tunnel log, including the quick URL
systemctl status cloudflared-tunnel is it running, and why not
A tunnel that connects but returns 502 is almost always the local port: nothing
is listening on it, or it is bound to an interface the service user cannot
reach. In dashboard mode, also check the public hostname's service URL.
-- Mike Mainguy <mike.mainguy@gmail.com>

View File

@ -0,0 +1,260 @@
#!/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

View File

@ -0,0 +1,25 @@
[Unit]
Description=Cloudflare Tunnel
After=network-online.target
Wants=network-online.target
# Written by cloudflared-tunnel-setup; until then this unit does nothing.
ConditionPathExists=/etc/cloudflared/tunnel.env
[Service]
Type=notify
User=cloudflared
Group=cloudflared
EnvironmentFile=/etc/cloudflared/tunnel.env
ExecStart=/usr/bin/cloudflared --no-autoupdate $CLOUDFLARED_ARGS
Restart=on-failure
RestartSec=5s
TimeoutStartSec=120
# Sandboxing
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,23 @@
#!/bin/sh
#
# cloudflared-update — upgrade cloudflared via apt and restart the tunnel only
# when the version actually changed. Run daily by cloudflared-update.timer.
#
# cloudflared's built-in autoupdate disables itself for apt installs, and the
# unprivileged, read-only service couldn't replace its own binary anyway.
set -eu
before=$(dpkg-query -W -f='${Version}' cloudflared)
# Refresh every source: cloudflared may come from Cloudflare's repo or from a
# mirror in your own. A broken unrelated source shouldn't block the upgrade.
apt-get update -qq || echo "apt-get update reported errors; continuing" >&2
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-tunnel.service
else
echo "cloudflared ${after} is up to date"
fi

View File

@ -0,0 +1,8 @@
[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/sbin/cloudflared-update

View File

@ -0,0 +1,10 @@
[Unit]
Description=Daily cloudflared update check
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target

320
setup-cloudflared.sh Executable file
View File

@ -0,0 +1,320 @@
#!/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