#!/usr/bin/env bash set -euo pipefail VERSION="0.0.3" UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)" USER_FILE="${UMBREL_ROOT}/db/user.json" APP_PROXY_SERVICE_NAME="app_proxy" REMOTE_TOR_ACCESS="false" if [[ -f "${USER_FILE}" ]]; then REMOTE_TOR_ACCESS=$(cat "${USER_FILE}" | jq 'has("remoteTorAccess") and .remoteTorAccess') fi show_help() { cat << EOF CLI (v${VERSION}) for managing Umbrel apps Usage: app [] Commands: install Pulls down images for an app and starts it uninstall Removes images and destroys all data for an app reinstall Calls 'uninstall', followed by 'install' for an app start Starts an installed app stop Stops an installed app restart Restarts an installed app compose Passes all arguments to docker-compose ls-installed Lists installed apps EOF } check_dependencies () { for cmd in "$@"; do if ! command -v $cmd >/dev/null 2>&1; then >&2 echo "This script requires \"${cmd}\" to be installed" exit 1 fi done } list_installed_apps() { cat "${USER_FILE}" 2> /dev/null | jq -r 'if has("installedApps") then .installedApps else [] end | join("\n")' || true } # Deterministically derives 128 bits of cryptographically secure entropy derive_entropy () { # Make sure we use the seed from the real Umbrel installation if this is # an OTA update. SEED_FILE="${UMBREL_ROOT}/db/umbrel-seed/seed" if [[ ! -f "${SEED_FILE}" ]] && [[ -f "${UMBREL_ROOT}/../.umbrel" ]]; then SEED_FILE="${UMBREL_ROOT}/../db/umbrel-seed/seed" fi identifier="${1}" umbrel_seed=$(cat "${SEED_FILE}") || true if [[ -z "$umbrel_seed" ]] || [[ -z "$identifier" ]]; then >&2 echo "Missing derivation parameter, this is unsafe, exiting." exit 1 fi # We need `sed 's/^.* //'` to trim the "(stdin)= " prefix from some versions of openssl printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${umbrel_seed}" | sed 's/^.* //' } # Setup env. for this context for a given app source_app() { local -r app="${1}" local -r app_domain="$(hostname -s 2>/dev/null || echo "umbrel").local" local -r app_entropy_identifier="app-${app}-seed" # Load in existing Umbrel .env # So that apps in their exports.sh can access # e.g. $TOR_PROXY_IP, $TOR_PROXY_PORT [[ -f "${UMBREL_ROOT}/.env" ]] && . "${UMBREL_ROOT}/.env" export NETWORK_IP="${NETWORK_IP}" # Set other useful vars. used in exports export DEVICE_HOSTNAME="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo "umbrel")" export DEVICE_DOMAIN_NAME="${DEVICE_HOSTNAME}.local" # Set env using all installed apps exports.sh # Do this first so that no app exports can # Override any app specific exports defined below EXPORTS_TOR_DATA_DIR="${UMBREL_ROOT}/tor/data" APPS_TO_SOURCE="$(list_installed_apps)" # $app might not be in the 'installed apps list' yet # i.e. If it is currently being installed # So we'll add it to the list of apps that will be 'sourced' if ! echo "${APPS_TO_SOURCE}" | grep --quiet "^${app}$"; then APPS_TO_SOURCE="${APPS_TO_SOURCE}"$'\n'"${app}" fi for EXPORTS_APP_ID in $APPS_TO_SOURCE; do EXPORTS_APP_DIR="${UMBREL_ROOT}/app-data/${EXPORTS_APP_ID}" EXPORTS_APP_FILE="${EXPORTS_APP_DIR}/exports.sh" EXPORTS_APP_DATA_DIR="${EXPORTS_APP_DIR}/data" [[ -f "${EXPORTS_APP_FILE}" ]] && . "${EXPORTS_APP_FILE}" || true done # App specific exports export APP_ID="${app}" export APP_MANIFEST_FILE="${app_data_dir}/umbrel-app.yml" export APP_VERSION=$(cat "${APP_MANIFEST_FILE}" | yq '.version') # This provides the app proxy with context of the app export APP_PROXY_HOSTNAME="app_proxy_${app}" export APP_PROXY_PORT=$(cat "${APP_MANIFEST_FILE}" | yq '.port') export APP_DATA_DIR="${app_data_dir}" export APP_DOMAIN="${app_domain}" export APP_HIDDEN_SERVICE="not-enabled.onion" if [[ "${REMOTE_TOR_ACCESS}" == "true" ]]; then export APP_HIDDEN_SERVICE="$(cat "${app_hidden_service_file}" 2>/dev/null || echo "notyetset.onion")" fi export APP_SEED=$(derive_entropy "${app_entropy_identifier}") export APP_PASSWORD=$(derive_entropy "${app_entropy_identifier}-APP_PASSWORD") # Tor specific exports export TOR_DATA_DIR="${UMBREL_ROOT}/tor/data" export TOR_ENTRYPOINT_SCRIPT="${UMBREL_ROOT}/scripts/support/tor-entrypoint.sh" export TOR_HS_APP_DIR="/data/app-${app}" export TOR_HS_PORTS="80:${APP_PROXY_HOSTNAME}:${APP_PROXY_PORT}" tor_extra_hs_varname=$(echo "APP_${APP_ID^^}_TOR_HS_EXTRA_PORTS" | tr '-' '_') tor_hs_extra_ports="${!tor_extra_hs_varname:-}" if [[ ! -z "${tor_hs_extra_ports}" ]]; then export TOR_HS_PORTS="${TOR_HS_PORTS} ${tor_hs_extra_ports}" fi # Other export UMBREL_ROOT } # Check dependencies check_dependencies docker-compose jq yq openssl envsubst if [ -z ${1+x} ]; then command="" else command="$1" fi # Lists installed apps if [[ "$command" = "ls-installed" ]]; then list_installed_apps exit fi if [ -z ${2+x} ]; then show_help exit 1 else app="$2" repo=$(cat "${USER_FILE}" 2> /dev/null | jq -r ".appOrigin.\"${app}\"" || true) repo_path=$("${UMBREL_ROOT}/scripts/repo" "path" "${repo}") app_repo_dir="${repo_path}/${app}" app_data_dir="${UMBREL_ROOT}/app-data/${app}" app_hidden_service_file="${UMBREL_ROOT}/tor/data/app-${app}/hostname" if [[ "${app}" == "installed" ]]; then for app in $(list_installed_apps); do if [[ "${app}" != "" ]]; then "${0}" "${1}" "${app}" "${@:3}" & fi done wait exit fi if [[ -z "${app}" ]]; then >&2 echo "Error: \"${app}\" is not a valid app" exit 1 fi fi if [ -z ${3+x} ]; then args="" else args="${@:3}" fi execute_hook() { local -r app="${1}" local -r name="${2}" local -r app_hooks_dir="${UMBREL_ROOT}/app-data/${app}/hooks" local -r hook="${app_hooks_dir}/${name}" if [[ -x "${hook}" ]]; then echo "Executing hook: ${hook}" # Swallow non-zero exit code "${hook}" || true fi } compose() { local -r app="${1}" shift # Source env. source_app "${app}" # Define support compose files local -r app_proxy_compose_file="${UMBREL_ROOT}/scripts/support/docker-compose.app_proxy.yml" local -r tor_compose_file="${UMBREL_ROOT}/scripts/support/docker-compose.tor.yml" local -r common_compose_file="${UMBREL_ROOT}/scripts/support/docker-compose.common.yml" local -r app_compose_file="${app_data_dir}/docker-compose.yml" local -r umbrel_env_file="${UMBREL_ROOT}/.env" # We need to use the proxy compose file first # To allow vars. in the app's compose file to override variables compose_files=() # Detect if the 'app_proxy' service has been defined # In the app's docker-compose file has_app_proxy_service=$(cat "${app_compose_file}" | yq ".services | has(\"${APP_PROXY_SERVICE_NAME}\")") if [[ "${has_app_proxy_service}" == "true" ]]; then compose_files+=( "--file" "${app_proxy_compose_file}" ) fi # If remote Tor access is enabled # Then include a compose file for Tor if [[ "${REMOTE_TOR_ACCESS}" == "true" ]]; then compose_files+=( "--file" "${tor_compose_file}" ) fi # Add app's compose file last so that it can override # Any of the other compose files compose_files+=( "--file" "${common_compose_file}" ) compose_files+=( "--file" "${app_compose_file}" ) # Merge compose files and args. passed into 'compose' compose_args=("${compose_files[@]}" "${@}") docker-compose \ --env-file "${umbrel_env_file}" \ --project-name "${app}" \ "${compose_args[@]}" } update_installed_apps() { local -r action="${1}" local -r app="${2}" local -r repo="${3:-null}" while ! (set -o noclobber; echo "$$" > "${USER_FILE}.lock") 2> /dev/null; do echo "Waiting for JSON lock to be released for ${app} update..." sleep 1 done # This will cause the lock-file to be deleted in case of a # premature exit. trap "rm -f "${USER_FILE}.lock"; exit $?" INT TERM EXIT [[ "${action}" == "add" ]] && operator="+" || operator="-" updated_json=$(cat "${USER_FILE}" | jq ".installedApps |= (. ${operator} [\"${app}\"] | unique)") echo "${updated_json}" > "${USER_FILE}" if [[ "${action}" == "add" ]]; then updated_json=$(cat "${USER_FILE}" | jq ".appOrigin |= (. ${operator} {\"${app}\":\"${repo}\"})") else updated_json=$(cat "${USER_FILE}" | jq "del(.appOrigin.\"${app}\")") fi echo "${updated_json}" > "${USER_FILE}" rm -f "${USER_FILE}.lock" } template_app() { local -r app="${1}" # Loop over all templates within app and populate them APP_TEMPLATE_FILES="${app_data_dir}/*.template" shopt -s nullglob for APP_TEMPLATE_INPUT_FILE in $APP_TEMPLATE_FILES; do # Output filename is the same as input with .template stripped off APP_TEMPLATE_OUTPUT_FILE="${APP_TEMPLATE_INPUT_FILE%.*}" # First we'll copy the file so we ensure the output # has the same fs permissions as the input cp --archive "${APP_TEMPLATE_INPUT_FILE}" "${APP_TEMPLATE_OUTPUT_FILE}" cat "${APP_TEMPLATE_INPUT_FILE}" | envsubst > "${APP_TEMPLATE_OUTPUT_FILE}" done } copy_app_files() { local -r files_to_copy="${1}" for filename in $files_to_copy; do APP_FILES="${app_repo_dir}/${filename}" for app_file in $APP_FILES; do if [[ -f "${app_file}" ]] || [[ -d "${app_file}" ]]; then cp --archive "${app_file}" "${app_data_dir}" fi done done } wait_for_tor_hs() { local -r app="${1}" # Check if the app's hidden service hostname # Has been already generated and exit early if [[ -f "${app_hidden_service_file}" ]]; then return fi # Check that the app has the App Proxy service defined local -r app_compose_file="${app_data_dir}/docker-compose.yml" has_app_proxy_service=$(cat "${app_compose_file}" | yq ".services | has(\"${APP_PROXY_SERVICE_NAME}\")") if [[ "${has_app_proxy_service}" == "false" ]]; then echo >&2 echo "Warning: \"${app}\" has no '${APP_PROXY_SERVICE_NAME}' defined" >&2 echo " \"${app}\" needs this to generate Tor HS" echo return fi # If a tor service will start # and there is no existing tor hs hostname # Let's allow 10 seconds to generate it and then start the app if [[ "${REMOTE_TOR_ACCESS}" == "true" ]]; then echo "Generating hidden services for ${app}..." # We must first start the App Proxy # So that it's hostname is resolvable by Tor # More details here: https://github.com/torproject/tor/blob/01bda6c23f58947ad1e20ea6367a5c260f53dfab/src/feature/hs/hs_common.c#L743 # And here: https://github.com/torproject/tor/blob/22552ad88e1e95ef9d2c6655c7602b7b25836075/src/lib/net/resolve.c#L297 # Otherwise Tor will throw this error: # Unparseable address in hidden service port configuration. compose "${app}" up --detach app_proxy compose "${app}" up --detach tor_server for attempt in $(seq 1 100); do if [[ -f "${app_hidden_service_file}" ]]; then echo "Hidden service file created successfully!" break fi sleep 0.1 done if [[ ! -f "${app_hidden_service_file}" ]]; then echo "Hidden service file wasn't created" fi fi } start_app() { local -r app="${1}" # Source env. source_app "${app}" # Now apply templates template_app "${app}" # Wait for Tor's HS hostname to exist wait_for_tor_hs "${app}" execute_hook "${app}" "pre-start" # Start all the app's containers compose "${app}" up --detach execute_hook "${app}" "post-start" } # Check that the app is installed must_be_installed_guard() { if ! list_installed_apps | grep --quiet "^${app}$"; then >&2 echo "Error: app \"${app}\" is not installed yet" exit 1 fi } # Pulls down images for an app and starts it if [[ "$command" = "install" ]]; then repo=$("${UMBREL_ROOT}/scripts/repo" "locate" "${app}") if [[ -z "${repo}" ]]; then >&2 echo "Error: \"${app}\" not found in any local app repo" exit 1 fi app_repo_dir=$("${UMBREL_ROOT}/scripts/repo" "path" "${repo}") app_repo_dir="${app_repo_dir}/${app}" echo "Installing '${app}' from: ${repo}" echo "Setting up data dir for app ${app}..." mkdir -p "${app_data_dir}" # Copy all app files rsync --archive --verbose --exclude ".gitkeep" "${app_repo_dir}/." "${app_data_dir}" execute_hook "${app}" "pre-install" # Source env. source_app "${app}" # Now apply templates template_app "${app}" echo "Pulling images for app ${app}..." compose "${app}" pull if [[ "$*" != *"--skip-start"* ]]; then echo "Starting app ${app}..." start_app "${app}" fi echo "Saving app ${app} in DB..." update_installed_apps add "${app}" "${repo}" execute_hook "${app}" "post-install" echo "Successfully installed app ${app}" exit fi # Removes images and destroys all data for an app if [[ "$command" = "uninstall" ]]; then must_be_installed_guard execute_hook "${app}" "pre-uninstall" # If a post uninstal hook exists # Then make a copy before it's deleted below app_hooks_dir="${UMBREL_ROOT}/app-data/${app}/hooks" post_uninstall_app_hook="${app_hooks_dir}/post-uninstall" if [[ -x "${post_uninstall_app_hook}" ]]; then temp_post_uninstall_app_hook="/tmp/${app}-post-uninstall" cp --archive "${post_uninstall_app_hook}" "${temp_post_uninstall_app_hook}" post_uninstall_app_hook="${temp_post_uninstall_app_hook}" else post_uninstall_app_hook="" fi echo "Removing images for app ${app}..." compose "${app}" down --rmi all --remove-orphans echo "Deleting app data for app ${app}..." if [[ -d "${app_data_dir}" ]]; then rm -rf "${app_data_dir}" fi echo "Removing app ${app} from DB..." update_installed_apps remove "${app}" if [[ ! -z "${post_uninstall_app_hook}" ]]; then "${post_uninstall_app_hook}" || true rm -rf "${post_uninstall_app_hook}" fi echo "Successfully uninstalled app ${app}" exit fi # Stops an installed app if [[ "$command" = "stop" ]]; then must_be_installed_guard execute_hook "${app}" "pre-stop" echo "Stopping app ${app}..." compose "${app}" rm --force --stop execute_hook "${app}" "post-stop" exit fi if [[ "$command" = "reinstall" ]]; then "${0}" "uninstall" "${app}" echo "${0}" "install" "${app}" exit fi # Starts an installed app if [[ "$command" = "start" ]]; then must_be_installed_guard echo "Starting app ${app}..." start_app "${app}" exit fi # Restarts an installed app if [[ "$command" = "restart" ]]; then "${0}" "stop" "${app}" "${0}" "start" "${app}" exit fi # Update an installed app if [[ "$command" = "update" ]]; then must_be_installed_guard # Check that the app folder still exists # Within the associated local app repo if [[ ! -d "${app_repo_dir}" ]]; then >&2 echo "Error: Local app repo no longer exists for ${app}" exit 1 fi echo "Updating '${app}' from: ${repo}" # Save current images to clean up later app_compose_file="${app_data_dir}/docker-compose.yml" app_old_images=$(yq e '.services | map(select(.image != null)) | .[].image' "${app_compose_file}") if [[ "$*" != *"--skip-stop"* ]]; then "${0}" "stop" "${app}" fi execute_hook "${app}" "pre-update" # App updates will only copy files from this whitelist: UPDATE_FILES_WHITELIST_PRE="docker-compose.yml *.template exports.sh torrc hooks" # We copy umbrel-app.yml after the app has started # That way the frontend knows the update has finished # And the app is running again UPDATE_FILES_WHITELIST_POST="umbrel-app.yml" copy_app_files "${UPDATE_FILES_WHITELIST_PRE}" # Ensure remaining files are copied in case of unexpected exit trap "copy_app_files "${UPDATE_FILES_WHITELIST_POST}"; exit $?" INT TERM EXIT # Source env. after new exports.sh is copied (done above via 'copy_app_files') source_app "${app}" # Now apply templates template_app "${app}" echo "Pulling images for app ${app}..." compose "${app}" pull # Copy remaining files to mark update as complete copy_app_files "${UPDATE_FILES_WHITELIST_POST}" if [[ "$*" != *"--skip-start"* ]]; then "${0}" "start" "${app}" # Remove any old images we don't need anymore docker rmi $app_old_images || true fi execute_hook "${app}" "post-update" exit fi # Passes all arguments to docker-compose if [[ "$command" = "compose" ]]; then compose "${app}" ${args} exit fi # If we get here it means no valid command was supplied # Show help and exit show_help exit 1