diff --git a/.gitignore b/.gitignore index f964293..e8c8e25 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ .ssh .viminfo +# Python bytecode +__pycache__ +*.py[cod] + # umbrel-dev docker-compose.override.yml diff --git a/scripts/start b/scripts/start index ae140bc..751add1 100755 --- a/scripts/start +++ b/scripts/start @@ -31,8 +31,13 @@ check_dependencies rsync jq curl UMBREL_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." UMBREL_LOGS="${UMBREL_ROOT}/logs" +set_status="${UMBREL_ROOT}/scripts/umbrel-os/status-server/set-status" + +$set_status umbrel started + if [[ ! -d "$UMBREL_ROOT" ]]; then echo "Root dir does not exist '$UMBREL_ROOT'" + $set_status umbrel errored umbrel-root-missing exit 1 fi @@ -109,3 +114,5 @@ echo " http://${DEVICE_IP}" if [[ ! -z "${hidden_service_url:-}" ]]; then echo " http://${hidden_service_url}" fi + +$set_status umbrel completed diff --git a/scripts/umbrel-os/external-storage/monitor b/scripts/umbrel-os/external-storage/monitor index 2da070a..62a540b 100755 --- a/scripts/umbrel-os/external-storage/monitor +++ b/scripts/umbrel-os/external-storage/monitor @@ -6,6 +6,8 @@ UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../../..)" block_device="${1}" mount_point="${2}" +set_status="/sd-root/${UMBREL_ROOT}/scripts/umbrel-os/status-server/set-status" + check_if_not_already_running() { if ps ax | grep $0 | grep -v $$ | grep bash | grep -v grep then @@ -42,6 +44,7 @@ main () { done echo "Stopping Umbrel due to failed storage device check..." + $set_status mount errored monitor-check docker kill $(docker ps -aq) } diff --git a/scripts/umbrel-os/external-storage/mount b/scripts/umbrel-os/external-storage/mount index 6d7b4a7..80cf485 100755 --- a/scripts/umbrel-os/external-storage/mount +++ b/scripts/umbrel-os/external-storage/mount @@ -21,6 +21,8 @@ EXTERNAL_DOCKER_DIR="${MOUNT_POINT}/docker" SWAP_DIR="/swap" SWAP_FILE="${SWAP_DIR}/swapfile" +set_status="${UMBREL_ROOT}/scripts/umbrel-os/status-server/set-status" + check_root () { if [[ $UID != 0 ]]; then echo "This script must be run as root" @@ -143,6 +145,7 @@ copy_docker_to_external_storage () { } main () { + $set_status mount started echo "Running external storage mount script..." check_root check_dependencies sed wipefs parted mount sync umount @@ -164,6 +167,7 @@ main () { if [[ $retry_for_block_devices -gt 20 ]]; then echo "No block devices found in 20 tries..." echo "Exiting mount script without doing anything" + $set_status mount errored no-block-device exit 1 fi @@ -172,6 +176,7 @@ main () { if [[ $no_of_block_devices -gt 1 ]]; then echo "Multiple block devices found, only one drive is supported" echo "Exiting mount script without doing anything" + $set_status mount errored multiple-block-devices exit 1 fi @@ -192,6 +197,7 @@ main () { if [[ $retry_for_usb_devices -gt 10 ]]; then echo "USB devices weren't registered after 10 tries..." echo "Exiting mount script without doing anything" + $set_status mount errored rebinding-failed exit 1 fi @@ -206,16 +212,16 @@ main () { mount_partition "${partition_path}" echo "Checking if device contains an Umbrel install..." - + if [[ -f "${EXTERNAL_UMBREL_ROOT}"/.umbrel ]]; then echo "Yes, it contains an Umbrel install" - else + else echo "No, it doesn't contain an Umbrel install" echo "Unmounting partition..." unmount_partition setup_new_device $block_device $partition_path fi - + else echo "No, it's not ext4" setup_new_device $block_device $partition_path @@ -266,6 +272,7 @@ main () { ${UMBREL_ROOT}/scripts/umbrel-os/external-storage/monitor ${block_device} ${MOUNT_POINT} & echo "Mount script completed successfully!" + $set_status mount completed } main diff --git a/scripts/umbrel-os/external-storage/update-from-sdcard b/scripts/umbrel-os/external-storage/update-from-sdcard index 3a93c82..3092446 100755 --- a/scripts/umbrel-os/external-storage/update-from-sdcard +++ b/scripts/umbrel-os/external-storage/update-from-sdcard @@ -6,6 +6,8 @@ UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../../..)" SD_MOUNT_POINT="/sd-root" SD_UMBREL_ROOT="${SD_MOUNT_POINT}${UMBREL_ROOT}" +set_status="${UMBREL_ROOT}/scripts/umbrel-os/status-server/set-status" + check_root () { if [[ $UID != 0 ]]; then echo "This script must be run as root" @@ -31,12 +33,14 @@ check_semver_range () { main () { check_root check_dependencies jq + $set_status sdcard-update started echo "Checking if SD card Umbrel is newer than external storage..." local external_version=$(cat "${UMBREL_ROOT}/info.json" | jq -r .version | cut -d "-" -f "1") local sd_version=$(cat "${SD_UMBREL_ROOT}/info.json" | jq -r .version | cut -d "-" -f "1") if ! check_semver_range ">${external_version}" "${sd_version}"; then echo "No, SD version is not newer, exiting." + $set_status sdcard-update completed exit 0 fi @@ -48,11 +52,13 @@ main () { echo "Checking if the external storage version \"${external_version}\" satisfies update requirement \"${update_requirement}\"..." if ! check_semver_range "${update_requirement}" "${external_version}"; then echo "No, we can't do an automatic update, exiting." + $set_status sdcard-update errored semver-mismatch exit 0 fi echo "Yes, it does, attempting an automatic update..." "${UMBREL_ROOT}/scripts/update/update" --path "${SD_UMBREL_ROOT}" + $set_status sdcard-update completed } main diff --git a/scripts/umbrel-os/services/umbrel-status-server-iptables-update.service b/scripts/umbrel-os/services/umbrel-status-server-iptables-update.service new file mode 100644 index 0000000..14434ea --- /dev/null +++ b/scripts/umbrel-os/services/umbrel-status-server-iptables-update.service @@ -0,0 +1,25 @@ +# Umbrel Status Server iptables Update +# Installed at /etc/systemd/system/umbrel-status-server-iptables-update.service + +# This is needed because when Docker starts it appends its own iptables rules +# after ours. This means traffic on port 80 will never arrive at a Docker container +# because we always redirect it. We can remove the rule and then re-apply it so +# it gets appended after the Docker rule so port 80 will only continue to be +# routed to the status server until a Docker container listens on port 80. + +[Unit] +Description=Status Server iptables Update +Wants=docker.service +After=docker.service + +[Service] +Type=oneshot +ExecStart=/status-server/setup-iptables +User=root +Group=root +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=status server iptables + +[Install] +WantedBy=multi-user.target diff --git a/scripts/umbrel-os/services/umbrel-status-server.service b/scripts/umbrel-os/services/umbrel-status-server.service new file mode 100644 index 0000000..4133277 --- /dev/null +++ b/scripts/umbrel-os/services/umbrel-status-server.service @@ -0,0 +1,21 @@ +# Umbrel Status Server +# Installed at /etc/systemd/system/umbrel-status-server.service + +[Unit] +Description=Status Server +Before=umbrel-external-storage-sdcard-update.service +Before=umbrel-external-storage.service +Before=umbrel-startup.service + +[Service] +Type=exec +ExecStartPre=/home/umbrel/umbrel/scripts/umbrel-os/status-server/setup +ExecStart=/status-server/status-server +User=root +Group=root +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=status server + +[Install] +WantedBy=multi-user.target diff --git a/scripts/umbrel-os/status-server/set-status b/scripts/umbrel-os/status-server/set-status new file mode 100755 index 0000000..0231de0 --- /dev/null +++ b/scripts/umbrel-os/status-server/set-status @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +STATUS_FILE_PATH="/umbrel-status" + +service_id="${1}" +status="${2}" +error_code="${3}" + +source /etc/default/umbrel 2> /dev/null +if [[ -z "${UMBREL_OS:-}" ]]; then + echo "Skipping status update when not on Umbrel OS" + exit +fi + +if [[ "${service_id}" == "" ]]; then + echo "Error: Missing ID" + exit 1 +fi + +if [[ "${status}" == "" ]]; then + echo "Error: Missing Status" + exit 1 +fi + +entry="${service_id}:${status}" + +if [[ "${error_code}" != "" ]]; then + entry="${entry}:${error_code}" +fi + +echo "${entry}" >> "${STATUS_FILE_PATH}" diff --git a/scripts/umbrel-os/status-server/setup b/scripts/umbrel-os/status-server/setup new file mode 100755 index 0000000..fe9ee22 --- /dev/null +++ b/scripts/umbrel-os/status-server/setup @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../../..)" +BIND_MOUNT_PATH="/status-server" +STATUS_FILE_PATH="/umbrel-status" + +# Bind mount status server to new location so we always run on the SD card +echo "Bind mounting status server to ${BIND_MOUNT_PATH}..." +[[ ! -d "${BIND_MOUNT_PATH}" ]] && mkdir -p "${BIND_MOUNT_PATH}" +mount --bind "${UMBREL_ROOT}/scripts/umbrel-os/status-server/" "${BIND_MOUNT_PATH}" +sync +sleep 1 + +# Clear status file +echo "clearing status file..." +echo "" > "${STATUS_FILE_PATH}" + +# Append iptables rule to forward port 80 to port 8000 +# The status server runs on port 8000 but this rule will route all port 80 +# HTTP traffic to it. +# When the Umbrel service has started Docker will overwrite this rule and +# instead forward port 80 to the Umbrel HTTP server container. +echo "Setting iptables rules..." +"${BIND_MOUNT_PATH}/setup-iptables" diff --git a/scripts/umbrel-os/status-server/setup-iptables b/scripts/umbrel-os/status-server/setup-iptables new file mode 100755 index 0000000..12951b5 --- /dev/null +++ b/scripts/umbrel-os/status-server/setup-iptables @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../../..)" + +check_root () { + if [[ $UID != 0 ]]; then + echo "This script must be run as root" + exit 1 + fi +} + +main () { + check_root + + # Remove and then re-append iptables rule + rule=(PREROUTING \ + --table nat \ + --proto tcp \ + --dport 80 \ + --jump REDIRECT \ + --to-port 8000) + if iptables --delete ${rule[@]} 2> /dev/null; then + echo "Removed existing iptables entry." + else + echo "No existing iptables entry found." + fi + iptables --append ${rule[@]} + echo "Appended new iptables entry." +} + +main diff --git a/scripts/umbrel-os/status-server/static/favicon.png b/scripts/umbrel-os/status-server/static/favicon.png new file mode 100644 index 0000000..2ee9d95 Binary files /dev/null and b/scripts/umbrel-os/status-server/static/favicon.png differ diff --git a/scripts/umbrel-os/status-server/static/index.html b/scripts/umbrel-os/status-server/static/index.html new file mode 100644 index 0000000..34ec82f --- /dev/null +++ b/scripts/umbrel-os/status-server/static/index.html @@ -0,0 +1,114 @@ + + + + + Umbrel + + + + + + + + + + + + + +
+
+ +

Starting Umbrel...

+
+ +
+ +

Restarting...

+

Please do not disconnect the power supply while the restart is in progress.

+
+ +
+ +

Shutting down...

+

Please do not disconnect the power supply until the shutdown is complete.

+
+ +
+ + Shutdown complete +
+ +
+ + +
+

Error: External drive disconnected

+

The external drive was disconnected while Umbrel was running. This can sometimes happen when using an unofficial Raspberry Pi power supply.

+
+ +
+

Error: Version mismatch

+

The version of UmbrelOS on your microSD card is not compatible with the version of Umbrel on your external drive.

+
+ +
+

Error: No external drive found

+

Please connect an external drive (at least 1TB) to a USB 3.0 port (blue color) on your Raspberry Pi and restart your Umbrel.

+
+ +
+

Error: Multiple external drives found

+

Umbrel only works with one external drive of atleast 1TB size. Please disconnect any additional external drives and restart your Umbrel.

+
+ +
+

Error: Failed to connect external drive

+

There was an error connecting your external drive. Please consider using the recommended hardware listed on getumbrel.com.

+
+ +

+ + + + Restart + + + + + + Shutdown + + + community +
+
+ + + + + diff --git a/scripts/umbrel-os/status-server/static/script.js b/scripts/umbrel-os/status-server/static/script.js new file mode 100644 index 0000000..c77571b --- /dev/null +++ b/scripts/umbrel-os/status-server/static/script.js @@ -0,0 +1,117 @@ +const isIframe = (window.self !== window.top); + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const on = (selector, eventName, callback) => { + for (element of document.querySelectorAll(selector)) { + element.addEventListener(eventName, event => { + event.preventDefault(); + callback(); + }); + } +}; + +const setState = (key, value) => document.body.dataset[key] = value; +const getState = key => document.body.dataset[key]; + +const isUmbrelUp = async () => { + const response = await fetch('/manager-api/ping'); + return response.status === 200 && response.redirected === false; +}; + +const checkForError = async () => { + const response = await fetch('/status'); + const status = await response.json(); + const errorCode = (status.find(service => service.status === 'errored') || {}).error; + return errorCode; +}; + +const isStatusServerUp = async () => { + try { + await checkForError(); + return true; + } catch (e) { + return false; + } +}; + +const power = async action => { + try { + const token = await (await fetch('/token')).json(); + const response = await fetch(`/${action}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({token}), + }); + if (!response.ok) { + return false; + } + } catch (e) {} finally { + return true; + } +}; + +on('.shutdown', 'click', async () => { + if (! await power('shutdown')) { + alert('Failed to shutdown Umbrel'); + return; + } + + setState('status', 'shutting-down'); + await delay(30000); + setState('status', 'shutdown-complete'); +}); + +on('.restart', 'click', async () => { + if (! await power('restart')) { + alert('Failed to restart Umbrel'); + return; + } + + setState('status', 'restarting'); + await delay(10000); + // Wait for Umbrel to come back up then reload the page. + while (true) { + try { + if (await isStatusServerUp() || await isUmbrelUp()) { + window.location.reload(); + } + } catch (e) {} + await delay(1000); + } +}); + +const main = async () => { + // Protect against clickjacking + if (isIframe) { + document.body.innerText = 'For security reasons Umbrel doesn\'t work in an iframe.'; + return; + } + + // Set initial loading state + setState('status', 'starting'); + + // Start loop + while (getState('status') === 'starting') { + try { + // If Umbrel is ready, reload + if (await isUmbrelUp()) { + window.location.reload(); + } + + // If there are errors, set error state + const error = await checkForError(); + if (error) { + setState('status', 'error'); + setState('error', error); + } + } catch (e) { + console.error(e); + } + await delay(1000); + } +}; + +main(); diff --git a/scripts/umbrel-os/status-server/static/styles.css b/scripts/umbrel-os/status-server/static/styles.css new file mode 100644 index 0000000..c56ba01 --- /dev/null +++ b/scripts/umbrel-os/status-server/static/styles.css @@ -0,0 +1,117 @@ +body { + background-color: #F7F9FB; + font-family: system-ui, -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", "Segoe UI", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + margin: 0; + padding: 0; + font-size: 0.9rem; + font-weight: 400; + line-height: 1.5; + color: #6c757d; + text-align: center; +} + +span, p { + margin: 0 0 1em 0; +} + +p { + display: flex; +} + +.container { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0 20px; +} + +.view { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; +} +[data-status="starting"] .view.starting, +[data-status="restarting"] .view.restarting, +[data-status="shutting-down"] .view.shutting-down, +[data-status="shutdown-complete"] .view.shutdown-complete, +[data-status="error"] .view.error { + display: flex; +} + +.error-text { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; +} +[data-error="monitor-check"] .error-text.monitor-check, +[data-error="semver-mismatch"] .error-text.semver-mismatch, +[data-error="no-block-device"] .error-text.no-block-device, +[data-error="multiple-block-devices"] .error-text.multiple-block-devices, +[data-error="rebinding-failed"] .error-text.rebinding-failed { + display: flex; +} + + +.logo { + max-height: 200px; + width: auto; + display: block; + margin-bottom: 80px; +} + +.logo.danger path { + fill: #F46E6E; +} + +.logo.dark path { + fill: #858997; +} + +.text-dark { + color: #141821; +} + +.spinner { + font-size: 80%; + font-weight: 400; + box-sizing: border-box; + display: inline-block; + vertical-align: text-bottom; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + width: 1rem; + height: 1rem; + border-width: 0.2em; + margin: 0.17em 1em 0 0; +} + +.button { + display: block; + margin-bottom: 1rem; + text-decoration: none; + color: #5351FB; +} + +.button svg { + margin-right: 0.1rem; +} + +.button.community { + position: absolute; + bottom: 0; + left: auto; + right: auto; +} + +.rotate { + animation: rotate 1s linear infinite; +} +@keyframes rotate { + from {transform: rotate(0deg)} + to {transform: rotate(360deg)} +} diff --git a/scripts/umbrel-os/status-server/status-server b/scripts/umbrel-os/status-server/status-server new file mode 100755 index 0000000..19bd077 --- /dev/null +++ b/scripts/umbrel-os/status-server/status-server @@ -0,0 +1,32 @@ +#!/usr/bin/env -S python3 -u +from pathlib import Path +import subprocess + +from util.server import Server +from util.csrf import get_csrf_token, verify_csrf_token +import util.status_file as status_file + +STATUS_FILE_PATH='/umbrel-status' + +def csrf(request): + if not verify_csrf_token(request['post_data']['token']): + raise Exception("Invalid CSRF token") + +def get_relative_path(path): + script_dir = Path(__file__).resolve().parent + return str(script_dir.joinpath(path)) + +def run(command): + if not status_file.contains_errors(STATUS_FILE_PATH): + raise Exception("Running commands is disabled if there are no status errors") + subprocess.run(command, check=True) + +def main(): + server = Server(directory=get_relative_path('static')) + server.get('/status', lambda _: status_file.parse(STATUS_FILE_PATH)) + server.get('/token', lambda _: get_csrf_token()) + server.post('/shutdown', csrf, lambda _: run(['poweroff'])) + server.post('/restart', csrf, lambda _: run(['reboot'])) + server.listen(8000) + +main() diff --git a/scripts/umbrel-os/status-server/util/csrf.py b/scripts/umbrel-os/status-server/util/csrf.py new file mode 100644 index 0000000..521f149 --- /dev/null +++ b/scripts/umbrel-os/status-server/util/csrf.py @@ -0,0 +1,9 @@ +import secrets + +csrf_token = secrets.token_hex(32) + +def get_csrf_token(): + return csrf_token + +def verify_csrf_token(token): + return token == get_csrf_token() diff --git a/scripts/umbrel-os/status-server/util/server.py b/scripts/umbrel-os/status-server/util/server.py new file mode 100644 index 0000000..539550c --- /dev/null +++ b/scripts/umbrel-os/status-server/util/server.py @@ -0,0 +1,88 @@ +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json + +class Server(): + def __init__(self, directory='.'): + self.directory = directory + self.get_routes = [] + self.post_routes = [] + + # Register a GET handler + def get(self, path, *handlers): + self.get_routes.append({'path': path, 'handlers': handlers}) + + # Register a POST handler + def post(self, path, *handlers): + self.post_routes.append({'path': path, 'handlers': handlers}) + + # Create the server + def listen(self, port): + directory = self.directory + post_routes = self.post_routes + get_routes = self.get_routes + + # Create request handler class + class Handler(SimpleHTTPRequestHandler): + # Patch the constructor with our directory + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=directory, **kwargs) + + def send_error(self, code, message=None): + if code == 404 and self.path != '/': + self.send_response(302) + self.send_header('Location', '/') + self.end_headers() + else: + super().send_error(code, message) + + # JSON helper + def send_json_response(self, status_code, data=None): + if status_code >= 400: + data = {'error': True} + self.send_response(status_code) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(bytes(json.dumps(data), 'utf8')) + + # Loop over routes and execute one if there's a match + def handle_routes(self, routes): + # Loop over the registered routes + for route in routes: + # Check if we have a match + if self.path == route['path']: + try: + request = {} + # Parse post data + if self.headers['Content-Length'] and self.headers['Content-Type'] == 'application/json': + content_length = int(self.headers['Content-Length']) + raw_post_data = self.rfile.read(content_length) + request['post_data'] = json.loads(raw_post_data.decode('utf-8')) + # Execute handlers + for handler in route['handlers']: + response = handler(request) + self.send_json_response(200, response) + except Exception as e: + # If it failed, send internal server error + print(f'Exception: {e}') + self.send_json_response(500) + # Route matched, return True + return True + # No routes matched, return False + return False + + # Try to match a route aganst a GET request + # else fall back to static server + def do_GET(self): + if not self.handle_routes(get_routes): + super().do_GET() + + # Try to match a route aganst a POST request + # else return 404 + def do_POST(self): + if not self.handle_routes(post_routes): + self.send_json_response(404) + + # Start HTTP server and attach handler + print(f'Server listening on port {port}...') + with ThreadingHTTPServer(('', port), Handler) as server: + server.serve_forever() diff --git a/scripts/umbrel-os/status-server/util/status_file.py b/scripts/umbrel-os/status-server/util/status_file.py new file mode 100644 index 0000000..1ea2f20 --- /dev/null +++ b/scripts/umbrel-os/status-server/util/status_file.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +from pathlib import Path + +# A status file has status entries, one per line, in the format: +# $id:$status[:$error-code] +# e.g: +# foo:started +# foo:completed +# bar:started +# fizz:started +# fizz:completed +# bar:errored:no-network-access + +# Creates an empty status file +def create_empty(status_file_path): + Path(status_file_path).open('w').close() + +# Returns the index of the dict with a matching ID from a list +def _get_index(list, id): + for i, dict in enumerate(list): + if dict['id'] == id: + return i + return None + +# Parses a status file +def parse(status_file_path): + # Empty list for holding the status entries + statuses = [] + # Read the status file and loop over the entries + for entry in Path(status_file_path).read_text().split(): + # Decode parts with ":" seperator + parts = entry.split(':') + parsed_entry = { + 'id': parts[0], + 'status': parts[1], + 'error': parts[2] if len(parts) >2 else None + } + # Check if we already have an entry for this id + index = _get_index(statuses, parsed_entry['id']) + # If we don't, append the entry + if index == None: + statuses.append(parsed_entry) + # If we do, update the entry + else: + statuses[index] = parsed_entry + return statuses + +# Checks if a status file contains errors +def contains_errors(status_file_path): + status = parse(status_file_path) + for entry in status: + if entry['status'] == 'errored': + return True + return False