WebDAV with Rclone and mTLS
WebDAV is an open extension to the HTTP protocol that lets you treat a web server like a remote filesystem. Rclone is an open source WebDAV client that lets you mount remote volumes for read/write access on your local computer.
Provision your WebDAV server
You can use any WebDAV server that you have access to. If you need to install one, I recommend copyparty or nextcloud (Use the d.rymcg.tech distribution to install these on Docker.)
Mutual TLS (optional)
For extra security, the server may require authentication with a
client TLS certificate, which this script fully supports. You will
just need to place the certificate file (e.g., ryan-files.pem) and
the unencrypted key file (e.g., ryan-files.key) and put them
someplace permanent (e.g., directly in the rclone config directory).
## Copy the cert and key file to someplace permanent:
mkdir -p ~/.config/rclone
cp ryan-files.pem ~/.config/rclone/
cp ryan-files.key ~/.config/rclone/
Note: the certificate should have been provided to you by your administrator. If you are deploying your own server, check out Step-CA (and the d.rymcg.tech distribution for installing it on Docker).
Example
In this example, let’s assume the following connection details when setting up the WebDAV service:
-
URL:
https://files.example.com- Note: for copyparty, there is usually no path necessary. For
Nextcloud, you need to specify the URL with a path, e.g.:
https://files.example.com/nextcloud/remote.php/dav/files/USERNAME/)
- Note: for copyparty, there is usually no path necessary. For
Nextcloud, you need to specify the URL with a path, e.g.:
-
Username:
ryan- HTTP authentication username.
- Note: copyparty ignores the username field.
-
Password:
hunter2- HTTP authentication password.
- Note: for copyparty, this is your only authentication (besides mTLS).
-
Volume name:
ryan-files- This is the internal name that Rclone will reference this remote with.
- This is also the default name of the mount point. (e.g.,
~/ryan-files)
-
Certificate:
~/.config/rclone/ryan-files.pem- Optional client TLS certificate.
-
Key:
~/.config/rclone/ryan-files.key- Optional client TLS key.
Download the script
wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/rclone/rclone_webdav.sh
chmod +x rclone_webdav.sh
Configuration
Create a new config (or update an existing one) named ryan-files:
./rclone_webdav.sh config ryan-files
Follow the prompts to configure the volume:
== Interactive rclone WebDAV setup (idempotent) ==
WebDAV URL (e.g., https://copyparty.example.com): https://files.example.com
Vendor (copyparty|owncloud|nextcloud|other) [copyparty]: copyparty
Username (HTTP Auth; username ignored copyparty): admin
Password (HTTP Auth): hunter2
Client certificate PEM path [/var/home/ryan/.config/rclone/client.crt]: /var/home/ryan/.config/rclone/ryan-files.pem
Client key PEM path [/var/home/ryan/.config/rclone/client.key]: /var/home/ryan/.config/rclone/ryan-files.key
Mount point (absolute) [/var/home/ryan/ryan-files]: /var/home/ryan/ryan-files
This will save the Rclone configuration to
~/.config/rclone/rclone.conf.
Install service
Install the systemd/User service to automatically mount the volume:
./rclone_webdav.sh enable ryan-files
Check service status
./rclone_webdav.sh status ryan-files
Now the remote volume should be mounted locally at ~/ryan-files and
will automatically mount when you login.
If you want the volume mounted on system boot (prior to first login), you may want to turn on systemd “lingering”:
# Allow user services to automatically start on system boot:
sudo loginctl enable-linger ${USER}
Check logs for debugging purposes
./rclone_webdav.sh log ryan-files
Uninstall service
./rclone_webdav.sh uninstall ryan-files
The script
#!/usr/bin/env bash
set -Eeuo pipefail
# === Settings ===
: "${XDG_CONFIG_HOME:="$HOME/.config"}"
RCLONE_CFG_DIR="$XDG_CONFIG_HOME/rclone"
MOUNT_POINTS_FILE="$RCLONE_CFG_DIR/mount_points.cfg"
SYSTEMD_USER_DIR="$XDG_CONFIG_HOME/systemd/user"
# Defaults for mount behavior (can be overridden via environment)
: "${RCLONE_VFS_CACHE_MODE:=writes}"
: "${RCLONE_DIR_CACHE_TIME:=5s}"
# === Helpers ===
_die() { echo "Error: $*" >&2; exit 1; }
_need() { command -v "$1" >/dev/null 2>&1 || _die "Required command '$1' not found"; }
_mkdirp() { mkdir -p "$1"; }
_yesno() { # _yesno "Prompt" default_yes|default_no
local prompt="$1" def="$2" ans defc
case "$def" in
default_yes) defc="Y/n";;
default_no) defc="y/N";;
*) _die "_yesno: second arg must be default_yes|default_no";;
esac
read -r -p "$prompt [$defc] " ans || true
ans="${ans,,}"
if [[ -z "$ans" ]]; then
[[ "$def" == "default_yes" ]] && return 0 || return 1
fi
[[ "$ans" == "y" || "$ans" == "yes" ]]
}
# Read mountpoint for a remote from ~/.config/rclone/mount_points.cfg
_get_mountpoint() {
local name="$1"
[[ -f "$MOUNT_POINTS_FILE" ]] || return 1
# lines like: name=/path/to/mount
awk -F= -v k="$name" '$1==k {print $2}' "$MOUNT_POINTS_FILE" | tail -n1
}
# Set/replace mountpoint entry
_set_mountpoint() {
local name="$1" mp="$2"
_mkdirp "$(dirname "$MOUNT_POINTS_FILE")"
touch "$MOUNT_POINTS_FILE"
# remove old line(s) for name
grep -v -e "^${name}=" "$MOUNT_POINTS_FILE" > "${MOUNT_POINTS_FILE}.tmp" || true
echo "${name}=${mp}" >> "${MOUNT_POINTS_FILE}.tmp"
mv "${MOUNT_POINTS_FILE}.tmp" "$MOUNT_POINTS_FILE"
}
# Systemd unit filename for a remote
_unit_name() {
local name="$1"
# keep it simple; allow letters, digits, dash/underscore
echo "rclone-${name}.service"
}
# Create (or overwrite) systemd --user unit for a remote
_write_unit() {
local name="$1"
local mp="$2"
local unit="$(_unit_name "$name")"
_mkdirp "$SYSTEMD_USER_DIR"
cat > "${SYSTEMD_USER_DIR}/${unit}" <<EOF
[Unit]
Description=Mount ${name} via rclone
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# You can tweak these defaults via environment variables.
Environment=RCLONE_VFS_CACHE_MODE=${RCLONE_VFS_CACHE_MODE}
Environment=RCLONE_DIR_CACHE_TIME=${RCLONE_DIR_CACHE_TIME}
ExecStart=/usr/bin/rclone mount ${name}: ${mp}
ExecStop=/bin/fusermount -u ${mp}
Restart=on-failure
[Install]
WantedBy=default.target
EOF
}
_print_help() {
cat <<'EOF'
Usage: rclone-webdav.sh <command> [args]
Commands:
help Show this help text.
config Interactive setup: creates/updates an rclone WebDAV remote
and saves its mount point in ~/.config/rclone/mount_points.cfg.
mount <name> Mount the remote now (foreground). Uses saved mount point.
enable <name> Install and enable a systemd --user service for the remote.
disable <name> Disable and stop the systemd --user service for the remote.
uninstall <name> Disable, stop, and remove the systemd --user service file.
status <name> Check status of the systemd unit.
log <name> Show log from the systemd unit.
Notes:
- mTLS (client cert/key) is optional. If enabled in the wizard, the paths are stored as
global.client_cert/global.client_key in rclone.conf for the remote.
- Mount options default to RCLONE_VFS_CACHE_MODE="writes" and RCLONE_DIR_CACHE_TIME="5s".
Override by exporting env vars before running, or edit the generated unit.
- Mount point paths are stored as entries in ~/.config/rclone/mount_points.cfg (format: NAME=/path).
EOF
}
_prompt_default() {
local prompt="$1" default="${2:-}"
local ans
if [[ -n "$default" ]]; then
read -r -p "$prompt [$default]: " ans || true
echo "${ans:-$default}"
else
read -r -p "$prompt: " ans || true
echo "$ans"
fi
}
_prompt_secret() {
local prompt="$1"
local ans
read -r -s -p "$prompt: " ans || true
echo
echo "$ans"
}
_cmd_config() {
_need rclone
echo "== Interactive rclone WebDAV setup (idempotent) =="
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 config <name>"
local url vendor user pass enable_mtls client_cert client_key mount_point
# --- prompts ---
url="$(_prompt_default 'WebDAV URL (e.g., https://copyparty.example.com)')"
vendor="$(_prompt_default 'Vendor (copyparty|owncloud|nextcloud|other)' 'copyparty')"
user="$(_prompt_default 'Username (HTTP Auth; username ignored by copyparty)')"
pass="$(_prompt_secret 'Password (HTTP Auth)')"
echo
if _yesno "Use mutual TLS (client certificate)?" default_no; then
enable_mtls=1
client_cert="$(_prompt_default 'Client certificate PEM path' "$HOME/.config/rclone/client.crt")"
client_key="$(_prompt_default 'Client key PEM path' "$HOME/.config/rclone/client.key")"
echo
# best-effort warnings if files missing; rclone will error if truly required
[[ -f "$client_cert" ]] || echo "Warning: client cert not found at '$client_cert' (continuing)">&2
[[ -f "$client_key" ]] || echo "Warning: client key not found at '$client_key' (continuing)">&2
else
enable_mtls=0
fi
local mp_default
mp_default="$(realpath "$HOME/${name}" 2>/dev/null || echo "$HOME/${name}")"
mount_point="$(_prompt_default 'Mount point (absolute)' "$mp_default")"
# --- sanitize values to avoid CR/LF sneaking in ---
sanitize() { printf %s "$1" | tr -d '\r\n'; }
name=$(sanitize "$name"); url=$(sanitize "$url"); vendor=$(sanitize "$vendor")
user=$(sanitize "$user"); pass=$(sanitize "$pass")
client_cert=$(sanitize "${client_cert:-}"); client_key=$(sanitize "${client_key:-}")
# vendor alias
[[ "$vendor" == "copyparty" ]] && vendor="owncloud"
echo
echo "Creating/updating rclone remote '$name'..."
# if exists, replace to keep idempotent
if rclone config show 2>/dev/null | grep -qxF "[$name]"; then
rclone config delete "$name" >/dev/null || true
fi
# build args array
# include mTLS settings only if enabled
args=( "$name" webdav "url=$url" "vendor=$vendor" "pacer_min_sleep=0.01ms" )
if [[ "$enable_mtls" == "1" ]]; then
args+=( "global.client_cert=$client_cert" "global.client_key=$client_key" )
fi
if [[ -n "$user" ]]; then
args+=( "user=$user" )
fi
if [[ -n "$pass" ]]; then
# use --obscure so rclone stores it safely
rclone config create "${args[@]}" "pass=$pass" --obscure
else
rclone config create "${args[@]}"
fi
echo "Saving mount point mapping: ${name} -> ${mount_point}"
_set_mountpoint "$name" "$mount_point"
echo "Done. Try:"
echo " $(basename "$0") mount $name"
echo " $(basename "$0") enable $name"
}
_cmd_mount() {
_need rclone
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 mount <name>"
local mp="$(_get_mountpoint "$name")" || _die "No mount point stored for '$name'. Run '$0 config' first."
# Ensure directory exists
_mkdirp "$mp"
echo "Temporarily mounting ${name}: at $mp"
echo "This process will now block as it services the mount."
echo "If you want to run in the background, you should enable the systemd unit instead."
echo "e.g., \`${BASH_SOURCE} enable ${name}\`"
exec rclone mount "${name}:" "$mp" \
--vfs-cache-mode "$RCLONE_VFS_CACHE_MODE" \
--dir-cache-time "$RCLONE_DIR_CACHE_TIME"
}
_cmd_enable() {
_need systemctl
_need rclone
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 enable <name>"
local mp="$(_get_mountpoint "$name")" || _die "No mount point stored for '$name'. Run '$0 config' first."
_write_unit "$name" "$mp"
systemctl --user daemon-reload
systemctl --user enable "$(_unit_name "$name")"
echo "Enabled systemd/User service: $(_unit_name "$name")"
systemctl --user start "$(_unit_name "$name")"
echo "Started systemd/User service: $(_unit_name "$name")"
echo "Waiting 5 seconds before checking status ..."
sleep 5
systemctl --user status "$(_unit_name "$name")"
}
_cmd_disable() {
_need systemctl
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 disable <name>"
local unit="$(_unit_name "$name")"
systemctl --user disable "$unit" || true
systemctl --user stop "$unit" || true
systemctl --user daemon-reload
systemctl --user status "$unit" || true
echo
echo "Disabled service: $unit."
echo "Stopped service: $unit"
}
_cmd_uninstall() {
_need systemctl
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 uninstall <name>"
local unit="$(_unit_name "$name")"
systemctl --user disable "$unit" || true
systemctl --user stop "$unit" || true
rm -f "${SYSTEMD_USER_DIR}/${unit}"
systemctl --user daemon-reload
systemctl --user status "$unit" || true
echo
echo "Removed ${unit}."
}
_cmd_status() {
_need systemctl
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 status <name>"
systemctl --user status "$(_unit_name "$name")"
}
_cmd_log() {
_need journalctl
local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 log <name>"
journalctl --user -u "$(_unit_name "$name")"
}
# === Main ===
cmd="${1:-help}"
case "$cmd" in
help|-h|--help) _print_help ;;
config) shift; _cmd_config "$@" ;;
mount) shift; _cmd_mount "$@" ;;
enable) shift; _cmd_enable "$@" ;;
disable) shift; _cmd_disable "$@" ;;
uninstall) shift; _cmd_uninstall "$@" ;;
status) shift; _cmd_status "$@" ;;
log|logs) shift; _cmd_log "$@" ;;
*) _die "Unknown command: $cmd. Try '$0 help'." ;;
esac
You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com
This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️