Files
configure-server/.includes/copyparty.sh
2026-04-17 20:40:55 -04:00

517 lines
15 KiB
Bash

#!/usr/bin/env bash
copypartyConfigFile="/etc/copyparty/copyparty.conf"
copypartyPort="3923"
copyparty_installed() {
pacman -Q copyparty &> /dev/null
}
ufw_installed() {
pacman -Q ufw &> /dev/null
}
valid_ipv4_subnet() {
local subnetValue="$1"
[[ "$subnetValue" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]] || return 1
awk -F '[./]' '
{
for (octetIndex = 1; octetIndex <= 4; octetIndex++) {
if ($octetIndex < 0 || $octetIndex > 255) {
exit 1
}
}
exit 0
}
' <<< "$subnetValue"
}
detect_private_subnets() {
ip -o -4 addr show up scope global | awk '
{
split($4, parts, "/")
split(parts[1], octets, ".")
if (octets[1] == 10) {
subnet = octets[1] "." octets[2] "." octets[3] ".0/24"
} else if (octets[1] == 192 && octets[2] == 168) {
subnet = octets[1] "." octets[2] "." octets[3] ".0/24"
} else if (octets[1] == 172 && octets[2] >= 16 && octets[2] <= 31) {
subnet = octets[1] "." octets[2] "." octets[3] ".0/24"
} else {
next
}
if (!seen[subnet]++) {
print subnet
}
}
'
}
detect_first_private_ipv4() {
ip -o -4 addr show up scope global | awk '
{
split($4, parts, "/")
split(parts[1], octets, ".")
if (octets[1] == 10 ||
(octets[1] == 192 && octets[2] == 168) ||
(octets[1] == 172 && octets[2] >= 16 && octets[2] <= 31)) {
print parts[1]
exit 0
}
}
'
}
show_copyparty_address() {
local lanAddress=""
if ! copyparty_installed; then
msgbox "Copyparty is not installed."
return 1
fi
lanAddress="$(detect_first_private_ipv4)"
if [[ -z "$lanAddress" ]]; then
msgbox "Copyparty is installed, but no private IPv4 address could be detected automatically."
return 1
fi
msgbox "Copyparty should be available at http://${lanAddress}:${copypartyPort}"
return 0
}
choose_lan_subnet() {
local detectedSubnet=""
local subnetChoice=""
detectedSubnet="$(detect_private_subnets | head -n 1)"
subnetChoice="$(inputbox "Confirm the LAN subnet for Copyparty access." "${detectedSubnet:-192.168.1.0/24}")" || return 1
if valid_ipv4_subnet "$subnetChoice"; then
printf '%s\n' "$subnetChoice"
return 0
fi
msgbox "Enter a valid IPv4 subnet such as 192.168.1.0/24."
return 1
}
install_copyparty() {
local packageList=(
copyparty
ffmpeg
cfssl
python-mutagen
python-pillow
python-pyvips
libkeyfinder
python-pyopenssl
python-pyzmq
python-argon2-cffi
)
if copyparty_installed; then
return 0
fi
if ! install_package "${packageList[@]}"; then
msgbox "Failed to install Copyparty and its optional feature packages."
return 1
fi
msgbox "Copyparty and its optional feature packages are installed."
return 0
}
list_local_users() {
awk -F ':' '
$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {
print $1
}
' /etc/passwd | sort
}
choose_copyparty_user() {
local userEntries=()
local userName=""
local selectedUser=""
while IFS= read -r userName; do
[[ -n "$userName" ]] && userEntries+=("$userName")
done < <(list_local_users)
if [[ "${#userEntries[@]}" -eq 0 ]]; then
msgbox "No regular local users were found."
return 1
fi
selectedUser="$(menulist --prompt "Please select the user Copyparty should be run as." "${userEntries[@]}")" || return 1
printf '%s\n' "$selectedUser"
return 0
}
account_name_exists() {
local accountsFile="$1"
local username="$2"
awk -F ':' -v username="$username" '
/^[[:space:]]+[A-Za-z0-9._-]+:/ {
accountName = $1
gsub(/^[[:space:]]+/, "", accountName)
if (accountName == username) {
exit 0
}
}
END {
exit 1
}
' "$accountsFile"
}
collect_copyparty_accounts() {
local tempAccountsFile=""
local username=""
local password=""
local addAnother="No"
tempAccountsFile="$(mktemp)"
while true; do
username="$(inputbox "Enter a Copyparty username.")" || {
rm -f "$tempAccountsFile"
return 1
}
if [[ -z "$username" || "$username" =~ [[:space:]:] ]]; then
msgbox "Usernames cannot be blank and cannot contain spaces or colons."
continue
fi
if account_name_exists "$tempAccountsFile" "$username"; then
msgbox "That Copyparty username already exists in this configuration."
continue
fi
password="$(passwordbox "Enter the password for ${username}.")" || {
rm -f "$tempAccountsFile"
return 1
}
if [[ -z "$password" ]]; then
msgbox "Passwords cannot be blank."
continue
fi
printf ' %s: %s\n' "$username" "$password" >> "$tempAccountsFile"
addAnother="$(yesno "Add another Copyparty account?")"
if [[ "$addAnother" != "Yes" ]]; then
break
fi
done
printf '%s\n' "$tempAccountsFile"
return 0
}
choose_share_mode() {
local shareMode=""
shareMode="$(menulist "Private read-write" "Private read-only" "Public read-only")" || return 1
printf '%s\n' "$shareMode"
return 0
}
append_share_config() {
local outputFile="$1"
local sharePath="$2"
local urlPath="$3"
local shareMode="$4"
local usernamesCsv="$5"
{
printf '\n[%s]\n' "$urlPath"
printf ' %s\n' "$sharePath"
printf ' accs:\n'
case "$shareMode" in
"Private read-write")
printf ' rwmd: %s\n' "$usernamesCsv"
printf ' flags:\n'
printf ' daw\n'
;;
"Private read-only")
printf ' r: %s\n' "$usernamesCsv"
;;
"Public read-only")
printf ' r: *\n'
;;
esac
} >> "$outputFile"
}
share_url_exists() {
local sharesFile="$1"
local urlPath="$2"
awk -v urlPath="$urlPath" '
/^\[[^]]+\]$/ {
sectionName = $0
sub(/^\[/, "", sectionName)
sub(/\]$/, "", sectionName)
if (sectionName == urlPath) {
exit 0
}
}
END {
exit 1
}
' "$sharesFile"
}
service_user_can_read_share() {
local serviceUser="$1"
local sharePath="$2"
# `sudoFlags` is initialized by the main launcher before sourcing this file.
# shellcheck disable=SC2154
sudo "${sudoFlags[@]}" -u "$serviceUser" test -r "$sharePath" &&
sudo "${sudoFlags[@]}" -u "$serviceUser" test -x "$sharePath"
}
service_user_can_write_share() {
local serviceUser="$1"
local sharePath="$2"
# `sudoFlags` is initialized by the main launcher before sourcing this file.
# shellcheck disable=SC2154
sudo "${sudoFlags[@]}" -u "$serviceUser" test -w "$sharePath" &&
sudo "${sudoFlags[@]}" -u "$serviceUser" test -x "$sharePath"
}
collect_copyparty_shares() {
local usernamesCsv="$1"
local serviceUser="$2"
local tempSharesFile=""
local sharePath=""
local urlPath=""
local shareMode=""
local addAnother="No"
tempSharesFile="$(mktemp)"
while true; do
sharePath="$(inputbox "Enter the local path to share.")" || {
rm -f "$tempSharesFile"
return 1
}
if [[ -z "$sharePath" ]]; then
msgbox "A local path is required."
continue
fi
# `sudoFlags` is initialized by the main launcher before sourcing this file.
# shellcheck disable=SC2154
if ! sudo "${sudoFlags[@]}" test -d "$sharePath"; then
msgbox "The path ${sharePath} does not exist or is not a directory."
continue
fi
if ! service_user_can_read_share "$serviceUser" "$sharePath"; then
msgbox "The selected service user ${serviceUser} cannot read ${sharePath}. Adjust permissions or choose another path."
continue
fi
urlPath="$(inputbox "Enter the Copyparty URL path for this share, for example /files." "/files")" || {
rm -f "$tempSharesFile"
return 1
}
if [[ ! "$urlPath" =~ ^/[^[:space:]]*$ ]]; then
msgbox "Enter a URL path that starts with / and contains no spaces."
continue
fi
if share_url_exists "$tempSharesFile" "$urlPath"; then
msgbox "That Copyparty URL path is already defined in this configuration."
continue
fi
shareMode="$(choose_share_mode)" || {
rm -f "$tempSharesFile"
return 1
}
if [[ "$shareMode" == "Private read-write" ]] &&
! service_user_can_write_share "$serviceUser" "$sharePath"; then
msgbox "The selected service user ${serviceUser} cannot write to ${sharePath}. Choose another path or adjust permissions."
continue
fi
append_share_config "$tempSharesFile" "$sharePath" "$urlPath" "$shareMode" "$usernamesCsv"
addAnother="$(yesno "Add another Copyparty share?")"
if [[ "$addAnother" != "Yes" ]]; then
break
fi
done
printf '%s\n' "$tempSharesFile"
return 0
}
join_account_names() {
local accountsFile="$1"
awk -F ':' '
BEGIN {
first = 1
}
/^[[:space:]]+[A-Za-z0-9._-]+:/ {
gsub(/^[[:space:]]+/, "", $1)
if (!first) {
printf ", "
}
printf "%s", $1
first = 0
}
END {
printf "\n"
}
' "$accountsFile"
}
write_copyparty_config() {
local serviceUser="$1"
local accountsFile="$2"
local sharesFile="$3"
local tempConfigFile=""
local serviceGroup=""
tempConfigFile="$(mktemp)"
serviceGroup="$(id -gn "$serviceUser")"
{
printf '[global]\n'
printf ' i: 0.0.0.0\n'
printf ' p: %s\n' "$copypartyPort"
printf ' e2dsa\n'
printf '\n[accounts]\n'
cat "$accountsFile"
cat "$sharesFile"
} > "$tempConfigFile"
# `sudoFlags` is initialized by the main launcher before sourcing this file.
# shellcheck disable=SC2154
if ! sudo "${sudoFlags[@]}" install -d -m 755 /etc/copyparty; then
rm -f "$tempConfigFile"
msgbox "Failed to create /etc/copyparty."
return 1
fi
# shellcheck disable=SC2154
if ! sudo "${sudoFlags[@]}" install -o root -g "$serviceGroup" -m 640 "$tempConfigFile" "$copypartyConfigFile"; then
rm -f "$tempConfigFile"
msgbox "Failed to write ${copypartyConfigFile}."
return 1
fi
rm -f "$tempConfigFile"
return 0
}
enable_copyparty_service() {
local serviceUser="$1"
local unitName="copyparty@${serviceUser}"
local configuredUnit=""
# `sudoFlags` is initialized by the main launcher before sourcing this file.
# shellcheck disable=SC2154
while IFS= read -r configuredUnit; do
[[ -z "$configuredUnit" || "$configuredUnit" == "$unitName" ]] && continue
sudo "${sudoFlags[@]}" systemctl disable --now "$configuredUnit" &> /dev/null || true
done < <(
sudo "${sudoFlags[@]}" find /etc/systemd/system -type l -name 'copyparty@*.service' -printf '%f\n' 2> /dev/null | sort -u
)
if ! sudo "${sudoFlags[@]}" systemctl enable "$unitName"; then
msgbox "Copyparty was configured, but the service failed to enable."
return 1
fi
if sudo "${sudoFlags[@]}" systemctl is-active --quiet "$unitName"; then
if ! sudo "${sudoFlags[@]}" systemctl restart "$unitName"; then
msgbox "Copyparty was configured, but the running service failed to restart."
return 1
fi
return 0
fi
if ! sudo "${sudoFlags[@]}" systemctl start "$unitName"; then
msgbox "Copyparty was configured, but the service failed to start."
return 1
fi
return 0
}
configure_copyparty_firewall() {
local lanSubnet=""
if ! ufw_installed; then
msgbox "ufw is not installed, so Copyparty will listen on ${copypartyPort} without helper-managed LAN restrictions. Install and configure ufw if you need local-only enforcement."
return 0
fi
if [[ "$(yesno "ufw is installed. Allow LAN-only access to Copyparty on port ${copypartyPort}?")" != "Yes" ]]; then
return 0
fi
lanSubnet="$(choose_lan_subnet)" || return 1
# `sudoFlags` is initialized by the main launcher before sourcing this file.
# shellcheck disable=SC2154
if ! sudo "${sudoFlags[@]}" ufw allow from "$lanSubnet" to any port "$copypartyPort" proto tcp; then
msgbox "Failed to allow Copyparty access for ${lanSubnet}."
return 1
fi
msgbox "Copyparty is allowed on ${copypartyPort}/tcp for ${lanSubnet}."
return 0
}
install_copyparty_flow() {
local serviceUser=""
local unitName=""
local accountsFile=""
local usernamesCsv=""
local sharesFile=""
local lanAddress=""
serviceUser="$(choose_copyparty_user)" || return 1
unitName="copyparty@${serviceUser}.service"
install_copyparty || return 1
accountsFile="$(collect_copyparty_accounts)" || return 1
usernamesCsv="$(join_account_names "$accountsFile")"
sharesFile="$(collect_copyparty_shares "$usernamesCsv" "$serviceUser")" || {
rm -f "$accountsFile"
return 1
}
write_copyparty_config "$serviceUser" "$accountsFile" "$sharesFile" || {
rm -f "$accountsFile" "$sharesFile"
return 1
}
rm -f "$accountsFile" "$sharesFile"
enable_copyparty_service "$serviceUser" || return 1
configure_copyparty_firewall || return 1
lanAddress="$(detect_first_private_ipv4)"
if [[ -n "$lanAddress" ]]; then
msgbox "Copyparty is configured. Enabled unit: ${unitName}. It should now be available at http://${lanAddress}:${copypartyPort}. For advanced tuning, edit ${copypartyConfigFile} as root."
else
msgbox "Copyparty is configured. Enabled unit: ${unitName}. For advanced tuning, edit ${copypartyConfigFile} as root."
fi
}
case "${1:-install}" in
install)
install_copyparty_flow
;;
show-address)
show_copyparty_address
;;
*)
msgbox "Unknown Copyparty action: ${1}"
return 1
;;
esac