commit d8e5f2796ee59dd61bd751b585df66571cf42327 Author: root Date: Sat Sep 19 00:40:33 2026 +0000 Initial Commit diff --git a/cloudflared-tunnel/.gitignore b/cloudflared-tunnel/.gitignore new file mode 100644 index 0000000..90c07ea --- /dev/null +++ b/cloudflared-tunnel/.gitignore @@ -0,0 +1,2 @@ +dist/ +publish.env diff --git a/cloudflared-tunnel/DEBIAN/control b/cloudflared-tunnel/DEBIAN/control new file mode 100644 index 0000000..4421fbb --- /dev/null +++ b/cloudflared-tunnel/DEBIAN/control @@ -0,0 +1,14 @@ +Package: cloudflared-tunnel +Version: @VERSION@ +Architecture: all +Maintainer: Mike Mainguy +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. diff --git a/cloudflared-tunnel/DEBIAN/postinst b/cloudflared-tunnel/DEBIAN/postinst new file mode 100755 index 0000000..7211944 --- /dev/null +++ b/cloudflared-tunnel/DEBIAN/postinst @@ -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 --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 diff --git a/cloudflared-tunnel/DEBIAN/postrm b/cloudflared-tunnel/DEBIAN/postrm new file mode 100755 index 0000000..baa1b06 --- /dev/null +++ b/cloudflared-tunnel/DEBIAN/postrm @@ -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 diff --git a/cloudflared-tunnel/DEBIAN/prerm b/cloudflared-tunnel/DEBIAN/prerm new file mode 100755 index 0000000..4359a7a --- /dev/null +++ b/cloudflared-tunnel/DEBIAN/prerm @@ -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 diff --git a/cloudflared-tunnel/README.md b/cloudflared-tunnel/README.md new file mode 100644 index 0000000..662388d --- /dev/null +++ b/cloudflared-tunnel/README.md @@ -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__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__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 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 \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 +``` diff --git a/cloudflared-tunnel/VERSION b/cloudflared-tunnel/VERSION new file mode 100644 index 0000000..4e379d2 --- /dev/null +++ b/cloudflared-tunnel/VERSION @@ -0,0 +1 @@ +0.0.2 diff --git a/cloudflared-tunnel/build.sh b/cloudflared-tunnel/build.sh new file mode 100755 index 0000000..3562a01 --- /dev/null +++ b/cloudflared-tunnel/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# build.sh — build dist/cloudflared-tunnel__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" diff --git a/cloudflared-tunnel/publish.sh b/cloudflared-tunnel/publish.sh new file mode 100755 index 0000000..7127c1a --- /dev/null +++ b/cloudflared-tunnel/publish.sh @@ -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__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" diff --git a/cloudflared-tunnel/src/README.Debian b/cloudflared-tunnel/src/README.Debian new file mode 100644 index 0000000..bfad01b --- /dev/null +++ b/cloudflared-tunnel/src/README.Debian @@ -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 (-). + + 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 --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 diff --git a/cloudflared-tunnel/src/cloudflared-tunnel-setup b/cloudflared-tunnel/src/cloudflared-tunnel-setup new file mode 100755 index 0000000..89a2b11 --- /dev/null +++ b/cloudflared-tunnel/src/cloudflared-tunnel-setup @@ -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 +# 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" < ${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" < 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 < Tunnels > your tunnel > + Public Hostname), set the service to: ${SERVICE_URL} +------------------------------------------------------------------ +EOF +fi diff --git a/cloudflared-tunnel/src/cloudflared-tunnel.service b/cloudflared-tunnel/src/cloudflared-tunnel.service new file mode 100644 index 0000000..3ef9306 --- /dev/null +++ b/cloudflared-tunnel/src/cloudflared-tunnel.service @@ -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 diff --git a/cloudflared-tunnel/src/cloudflared-update b/cloudflared-tunnel/src/cloudflared-update new file mode 100755 index 0000000..8ac4c3e --- /dev/null +++ b/cloudflared-tunnel/src/cloudflared-update @@ -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 diff --git a/cloudflared-tunnel/src/cloudflared-update.service b/cloudflared-tunnel/src/cloudflared-update.service new file mode 100644 index 0000000..bbe95c3 --- /dev/null +++ b/cloudflared-tunnel/src/cloudflared-update.service @@ -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 diff --git a/cloudflared-tunnel/src/cloudflared-update.timer b/cloudflared-tunnel/src/cloudflared-update.timer new file mode 100644 index 0000000..2a9bf49 --- /dev/null +++ b/cloudflared-tunnel/src/cloudflared-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily cloudflared update check + +[Timer] +OnCalendar=daily +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/setup-cloudflared.sh b/setup-cloudflared.sh new file mode 100755 index 0000000..2d77206 --- /dev/null +++ b/setup-cloudflared.sh @@ -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 +# 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" < ${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" < 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 < Tunnels > your tunnel > + Public Hostname), set the service to: ${SERVICE_URL} +------------------------------------------------------------------ +EOF +fi