From ab55d95fd4a2ca59839687bdc68e44a221d4935b Mon Sep 17 00:00:00 2001 From: Daniel Markstedt Date: Thu, 23 Dec 2021 09:12:16 -0800 Subject: [PATCH] Add token authentication to Web UI and OLED monitor script (#554) * Throw 403 forbidden if token auth is enabled * Use argparse to parse port and password parameters; use password token to authenticate * Move config file loading to before_first_request block * Add token auth to monitor script * Install script for enabling token auth * Token config flow for oled monitor installation * Correct permissions * Fix bug * Cleanup --- easyinstall.sh | 60 +++++++++++++++++++---- src/oled_monitor/ractl_cmds.py | 19 +++++++- src/oled_monitor/rascsi_oled_monitor.py | 63 ++++++++++++++++--------- src/oled_monitor/start.sh | 21 ++++----- src/web/file_cmds.py | 5 ++ src/web/ractl_cmds.py | 29 ++++++++++++ src/web/start.sh | 9 ++-- src/web/web.py | 43 +++++++++++++---- 8 files changed, 192 insertions(+), 57 deletions(-) diff --git a/easyinstall.sh b/easyinstall.sh index 14494628..129d7663 100755 --- a/easyinstall.sh +++ b/easyinstall.sh @@ -57,6 +57,7 @@ HFDISK_BIN=/usr/bin/hfdisk LIDO_DRIVER=$BASE/lido-driver.img GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) GIT_REMOTE=${GIT_REMOTE:-origin} +TOKEN="" set -e @@ -141,6 +142,14 @@ function installRaScsiScreen() { SCREEN_HEIGHT="32" fi + echo "" + echo "Is RaSCSI using token-based authentication? [y/N]" + read -r REPLY + if [ "$REPLY" == "y" ] || [ "$REPLY" == "Y" ]; then + echo -n "Enter the passphrase that you configured: " + read -r TOKEN + fi + stopRaScsiScreen updateRaScsiGit @@ -166,7 +175,13 @@ function installRaScsiScreen() { echo "Installing the monitor_rascsi.service configuration..." sudo cp -f "$BASE/src/oled_monitor/monitor_rascsi.service" "$SYSTEMD_PATH/monitor_rascsi.service" sudo sed -i /^ExecStart=/d "$SYSTEMD_PATH/monitor_rascsi.service" - sudo sed -i "8 i ExecStart=$BASE/src/oled_monitor/start.sh --rotation=$ROTATION --height=$SCREEN_HEIGHT" "$SYSTEMD_PATH/monitor_rascsi.service" + if [ ! -z "$TOKEN" ]; then + sudo sed -i "8 i ExecStart=$BASE/src/oled_monitor/start.sh --rotation=$ROTATION --height=$SCREEN_HEIGHT --password=$TOKEN" "$SYSTEMD_PATH/monitor_rascsi.service" + sudo chmod 600 "$SYSTEMD_PATH/monitor_rascsi.service" + echo "Granted access to the OLED Monitor with the token passphrase that you configured for RaSCSI." + else + sudo sed -i "8 i ExecStart=$BASE/src/oled_monitor/start.sh --rotation=$ROTATION --height=$SCREEN_HEIGHT" "$SYSTEMD_PATH/monitor_rascsi.service" + fi sudo systemctl daemon-reload sudo systemctl enable monitor_rascsi @@ -264,7 +279,27 @@ function backupRaScsiService() { # Modifies and installs the rascsi service function enableRaScsiService() { - sudo sed -i "s@^ExecStart.*@& -F $VIRTUAL_DRIVER_PATH@" "$SYSTEMD_PATH/rascsi.service" + echo "" + echo "Do you want to enable token-based access control for RaSCSI? [y/N]" + read REPLY + + if [ "$REPLY" == "y" ] || [ "$REPLY" == "Y" ]; then + echo -n "Enter the passphrase that you want to use: " + read -r TOKEN + if [ -f "$HOME/.rascsi_secret" ]; then + sudo rm "$HOME/.rascsi_secret" + echo "Removed old RaSCSI token file" + fi + echo "$TOKEN" > "$HOME/.rascsi_secret" + sudo chown root:root "$HOME/.rascsi_secret" + sudo chmod 600 "$HOME/.rascsi_secret" + sudo sed -i "s@^ExecStart.*@& -F $VIRTUAL_DRIVER_PATH -P $HOME/.rascsi_secret@" "$SYSTEMD_PATH/rascsi.service" + sudo chmod 600 "$SYSTEMD_PATH/rascsi.service" + echo "Configured to use $HOME/.rascsi_secret to secure RaSCSI. This file is readable by root only." + echo "Make note of your passphrase; you will need it to use rasctl, and other RaSCSI clients." + else + sudo sed -i "s@^ExecStart.*@& -F $VIRTUAL_DRIVER_PATH@" "$SYSTEMD_PATH/rascsi.service" + fi echo "Configured rascsi.service to use $VIRTUAL_DRIVER_PATH as default image dir." sudo systemctl daemon-reload @@ -279,7 +314,14 @@ function installWebInterfaceService() { echo "Installing the rascsi-web.service configuration..." sudo cp -f "$BASE/src/web/service-infra/rascsi-web.service" "$SYSTEMD_PATH/rascsi-web.service" sudo sed -i /^ExecStart=/d "$SYSTEMD_PATH/rascsi-web.service" - sudo sed -i "8 i ExecStart=$WEB_INSTALL_PATH/start.sh" "$SYSTEMD_PATH/rascsi-web.service" + echo "$TOKEN" + if [ ! -z "$TOKEN" ]; then + sudo sed -i "8 i ExecStart=$WEB_INSTALL_PATH/start.sh --password=$TOKEN" "$SYSTEMD_PATH/rascsi-web.service" + sudo chmod 600 "$SYSTEMD_PATH/rascsi-web.service" + echo "Granted access to the Web Interface with the token passphrase that you configured for RaSCSI." + else + sudo sed -i "8 i ExecStart=$WEB_INSTALL_PATH/start.sh" "$SYSTEMD_PATH/rascsi-web.service" + fi sudo systemctl daemon-reload sudo systemctl enable rascsi-web @@ -475,12 +517,12 @@ function setupWiredNetworking() { echo "WARNING: If you continue, the IP address of your Pi may change upon reboot." echo "Please make sure you will not lose access to the Pi system." echo "" - echo "Do you want to proceed with network configuration using the default settings? Y/n" + echo "Do you want to proceed with network configuration using the default settings? [Y/n]" read REPLY if [ "$REPLY" == "N" ] || [ "$REPLY" == "n" ]; then echo "Available wired interfaces on this system:" - echo `ip -o addr show scope link | awk '{split($0, a); print $2}' | grep eth` + echo `ip -o addr show scope link | awk '{split($0, a); print $2}' | grep eth` echo "Please type the wired interface you want to use and press Enter:" read SELECTED LAN_INTERFACE=$SELECTED @@ -530,12 +572,12 @@ function setupWirelessNetworking() { echo "Subnet Mask: $NETWORK_MASK" echo "DNS Server: Any public DNS server" echo "" - echo "Do you want to proceed with network configuration using the default settings? Y/n" + echo "Do you want to proceed with network configuration using the default settings? [Y/n]" read REPLY if [ "$REPLY" == "N" ] || [ "$REPLY" == "n" ]; then echo "Available wireless interfaces on this system:" - echo `ip -o addr show scope link | awk '{split($0, a); print $2}' | grep wlan` + echo `ip -o addr show scope link | awk '{split($0, a); print $2}' | grep wlan` echo "Please type the wireless interface you want to use and press Enter:" read -r WLAN_INTERFACE echo "Base IP address (ex. 10.10.20):" @@ -553,7 +595,7 @@ function setupWirelessNetworking() { read REPLY else sudo bash -c 'echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf' - echo "Modified /etc/sysctl.conf" + echo "Modified /etc/sysctl.conf" fi # Check if iptables is installed @@ -966,7 +1008,7 @@ while [ "$1" != "" ]; do ;; *) echo "ERROR: unknown option \"$VALUE\"" - exit 1 + exit 1 ;; esac shift diff --git a/src/oled_monitor/ractl_cmds.py b/src/oled_monitor/ractl_cmds.py index 81837479..31657700 100644 --- a/src/oled_monitor/ractl_cmds.py +++ b/src/oled_monitor/ractl_cmds.py @@ -6,13 +6,14 @@ from unidecode import unidecode from socket_cmds import send_pb_command import rascsi_interface_pb2 as proto -def device_list(): +def device_list(token): """ Sends a DEVICES_INFO command to the server. Returns a list of dicts with info on all attached devices. """ command = proto.PbCommand() command.operation = proto.PbOperation.DEVICES_INFO + command.params["token"] = token data = send_pb_command(command.SerializeToString()) result = proto.PbResult() result.ParseFromString(data) @@ -51,3 +52,19 @@ def device_list(): i += 1 return dlist + + +def is_token_auth(token): + """ + Sends a CHECK_AUTHENTICATION command to the server. + Tells you whether RaSCSI backend is protected by a token password or not. + Returns (bool) status and (str) msg. + """ + command = proto.PbCommand() + command.operation = proto.PbOperation.CHECK_AUTHENTICATION + command.params["token"] = token + + data = send_pb_command(command.SerializeToString()) + result = proto.PbResult() + result.ParseFromString(data) + return {"status": result.status, "msg": result.msg} diff --git a/src/oled_monitor/rascsi_oled_monitor.py b/src/oled_monitor/rascsi_oled_monitor.py index a7b0f95e..0d1dfaf0 100755 --- a/src/oled_monitor/rascsi_oled_monitor.py +++ b/src/oled_monitor/rascsi_oled_monitor.py @@ -30,6 +30,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import argparse from time import sleep from sys import argv from collections import deque @@ -38,29 +39,47 @@ from adafruit_ssd1306 import SSD1306_I2C from PIL import Image, ImageDraw, ImageFont from interrupt_handler import GracefulInterruptHandler from pi_cmds import get_ip_and_host -from ractl_cmds import device_list +from ractl_cmds import device_list, is_token_auth -# Read positional arguments; expecting exactly two, or none -# Arg 1 is the rotation in degrees, arg 2 is the screen height in pixels -# Valid values are 0/180 for ROTATION, 32/64 for HEIGHT -if len(argv) == 3: - if int(argv[1]) == 0: - ROTATION = 0 - else: - # 2 means 180 degrees - ROTATION = 2 - if int(argv[2]) == 64: - HEIGHT = 64 - LINES = 8 - else: - HEIGHT = 32 - LINES = 4 -else: - # Default settings +parser = argparse.ArgumentParser(description="RaSCSI OLED Monitor script") +parser.add_argument( + "--rotation", + type=int, + choices=[0, 180], + default=180, + action="store", + help="The rotation of the screen buffer in degrees", + ) +parser.add_argument( + "--height", + type=int, + choices=[32, 64], + default=32, + action="store", + help="The pixel height of the screen buffer", + ) +parser.add_argument( + "--password", + type=str, + default="", + action="store", + help="Token password string for authenticating with RaSCSI", + ) +args = parser.parse_args() + +if args.rotation == 0: + ROTATION = 0 +elif args.rotation == 180: ROTATION = 2 + +if args.height == 64: + HEIGHT = 64 + LINES = 8 +elif args.height == 32: HEIGHT = 32 LINES = 4 - print("No valid parameters detected; defaulting to 32 px height, 180 degrees rotation.") + +TOKEN = args.password WIDTH = 128 BORDER = 5 @@ -132,10 +151,12 @@ def formatted_output(): Formats the strings to be displayed on the Screen Returns a (list) of (str) output """ - rascsi_list = device_list() + rascsi_list = device_list(TOKEN) output = [] - if rascsi_list: + if not TOKEN and not is_token_auth(TOKEN)["status"]: + output.append("Permission denied!") + elif rascsi_list: for line in rascsi_list: if line["device_type"] in ("SCCD", "SCRM", "SCMO"): # Print image file name only when there is an image attached diff --git a/src/oled_monitor/start.sh b/src/oled_monitor/start.sh index 5e58754d..dbf2a330 100755 --- a/src/oled_monitor/start.sh +++ b/src/oled_monitor/start.sh @@ -112,24 +112,19 @@ while [ "$1" != "" ]; do VALUE=$(echo "$1" | awk -F= '{print $2}') case $PARAM in -r | --rotation) - ROTATION=$VALUE + ROTATION="--rotation $VALUE" ;; -h | --height) - HEIGHT=$VALUE + HEIGHT="--height $VALUE" + ;; + -P | --password) + PASSWORD="--password $VALUE" ;; *) echo "ERROR: unknown parameter \"$PARAM\"" exit 1 ;; esac - case $VALUE in - 0 | 180 | 32 | 64 ) - ;; - *) - echo "ERROR: invalid option \"$VALUE\"" - exit 1 - ;; - esac shift done @@ -137,11 +132,11 @@ echo "Starting OLED Screen..." if [ -z ${ROTATION+x} ]; then echo "No screen rotation parameter given; falling back to the default." else - echo "Screen rotation set to $ROTATION degrees." + echo "Starting with parameter $ROTATION" fi if [ -z ${HEIGHT+x} ]; then echo "No screen height parameter given; falling back to the default." else - echo "Screen height set to $HEIGHT px." + echo "Starting with parameter $HEIGHT" fi -python3 rascsi_oled_monitor.py ${ROTATION} ${HEIGHT} +python3 rascsi_oled_monitor.py ${ROTATION} ${HEIGHT} ${PASSWORD} diff --git a/src/web/file_cmds.py b/src/web/file_cmds.py index f4785b78..fb1469a0 100644 --- a/src/web/file_cmds.py +++ b/src/web/file_cmds.py @@ -5,6 +5,7 @@ Module for methods reading from and writing to the file system import os import logging from pathlib import PurePath +from flask import current_app from ractl_cmds import ( get_server_info, @@ -63,6 +64,7 @@ def list_images(): """ command = proto.PbCommand() command.operation = proto.PbOperation.DEFAULT_IMAGE_FILES_INFO + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -118,6 +120,7 @@ def create_new_image(file_name, file_type, size): """ command = proto.PbCommand() command.operation = proto.PbOperation.CREATE_IMAGE + command.params["token"] = current_app.config["TOKEN"] command.params["file"] = file_name + "." + file_type command.params["size"] = str(size) @@ -137,6 +140,7 @@ def delete_image(file_name): """ command = proto.PbCommand() command.operation = proto.PbOperation.DELETE_IMAGE + command.params["token"] = current_app.config["TOKEN"] command.params["file"] = file_name @@ -154,6 +158,7 @@ def rename_image(file_name, new_file_name): """ command = proto.PbCommand() command.operation = proto.PbOperation.RENAME_IMAGE + command.params["token"] = current_app.config["TOKEN"] command.params["from"] = file_name command.params["to"] = new_file_name diff --git a/src/web/ractl_cmds.py b/src/web/ractl_cmds.py index b3782c74..8f275c4d 100644 --- a/src/web/ractl_cmds.py +++ b/src/web/ractl_cmds.py @@ -4,6 +4,7 @@ Module for commands sent to the RaSCSI backend service. from settings import REMOVABLE_DEVICE_TYPES from socket_cmds import send_pb_command +from flask import current_app import rascsi_interface_pb2 as proto @@ -20,6 +21,7 @@ def get_server_info(): """ command = proto.PbCommand() command.operation = proto.PbOperation.SERVER_INFO + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -75,6 +77,7 @@ def get_reserved_ids(): """ command = proto.PbCommand() command.operation = proto.PbOperation.RESERVED_IDS_INFO + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -95,6 +98,7 @@ def get_network_info(): """ command = proto.PbCommand() command.operation = proto.PbOperation.NETWORK_INTERFACES_INFO + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -112,6 +116,7 @@ def get_device_types(): """ command = proto.PbCommand() command.operation = proto.PbOperation.DEVICE_TYPES_INFO + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -134,6 +139,7 @@ def attach_image(scsi_id, **kwargs): """ command = proto.PbCommand() + command.params["token"] = current_app.config["TOKEN"] devices = proto.PbDeviceDefinition() devices.id = int(scsi_id) @@ -204,6 +210,7 @@ def detach_by_id(scsi_id, unit=None): command = proto.PbCommand() command.operation = proto.PbOperation.DETACH command.devices.append(devices) + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -218,6 +225,7 @@ def detach_all(): """ command = proto.PbCommand() command.operation = proto.PbOperation.DETACH_ALL + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -239,6 +247,7 @@ def eject_by_id(scsi_id, unit=None): command = proto.PbCommand() command.operation = proto.PbOperation.EJECT command.devices.append(devices) + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -258,6 +267,7 @@ def list_devices(scsi_id=None, unit=None): from os import path command = proto.PbCommand() command.operation = proto.PbOperation.DEVICES_INFO + command.params["token"] = current_app.config["TOKEN"] # If method is called with scsi_id parameter, return the info on those devices # Otherwise, return the info on all attached devices @@ -333,6 +343,7 @@ def reserve_scsi_ids(reserved_scsi_ids): command = proto.PbCommand() command.operation = proto.PbOperation.RESERVE_IDS command.params["ids"] = ",".join(reserved_scsi_ids) + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -349,6 +360,7 @@ def set_log_level(log_level): command = proto.PbCommand() command.operation = proto.PbOperation.LOG_LEVEL command.params["level"] = str(log_level) + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() @@ -365,6 +377,23 @@ def shutdown_pi(mode): command = proto.PbCommand() command.operation = proto.PbOperation.SHUT_DOWN command.params["mode"] = str(mode) + command.params["token"] = current_app.config["TOKEN"] + + data = send_pb_command(command.SerializeToString()) + result = proto.PbResult() + result.ParseFromString(data) + return {"status": result.status, "msg": result.msg} + + +def is_token_auth(): + """ + Sends a CHECK_AUTHENTICATION command to the server. + Tells you whether RaSCSI backend is protected by a token password or not. + Returns (bool) status and (str) msg. + """ + command = proto.PbCommand() + command.operation = proto.PbOperation.CHECK_AUTHENTICATION + command.params["token"] = current_app.config["TOKEN"] data = send_pb_command(command.SerializeToString()) result = proto.PbResult() diff --git a/src/web/start.sh b/src/web/start.sh index cbc52f21..3cba5077 100755 --- a/src/web/start.sh +++ b/src/web/start.sh @@ -80,7 +80,10 @@ while [ "$1" != "" ]; do VALUE=$(echo "$1" | awk -F= '{print $2}') case $PARAM in -p | --port) - PORT=$VALUE + PORT="--port $VALUE" + ;; + -P | --password) + PASSWORD="--password $VALUE" ;; *) echo "ERROR: unknown parameter \"$PARAM\"" @@ -90,5 +93,5 @@ while [ "$1" != "" ]; do shift done -echo "Starting web server on port ${PORT:-8080}..." -python3 web.py ${PORT} +echo "Starting web server for RaSCSI Web Interface..." +python3 web.py ${PORT} ${PASSWORD} diff --git a/src/web/web.py b/src/web/web.py index e8550414..e38fd9dd 100644 --- a/src/web/web.py +++ b/src/web/web.py @@ -3,6 +3,7 @@ Module for the Flask app rendering and endpoints """ import logging +import argparse from sys import argv from pathlib import Path from functools import wraps @@ -18,6 +19,7 @@ from flask import ( send_from_directory, make_response, session, + abort, ) from file_cmds import ( @@ -58,6 +60,7 @@ from ractl_cmds import ( reserve_scsi_ids, set_log_level, shutdown_pi, + is_token_auth, ) from device_utils import ( sort_and_format_devices, @@ -79,12 +82,14 @@ from settings import ( APP = Flask(__name__) - @APP.route("/") def index(): """ Sets up data structures for and renders the index page """ + if not is_token_auth()["status"] and not APP.config["TOKEN"]: + abort(403, "RaSCSI is password protected. Start the Web Interface with the --password parameter.") + server_info = get_server_info() disk = disk_space() devices = list_devices() @@ -922,20 +927,38 @@ def unzip(): return redirect(url_for("index")) +@APP.before_first_request +def load_default_config(): + """ + Load the default configuration file, if found + """ + if Path(f"{CFG_DIR}/{DEFAULT_CONFIG}").is_file(): + read_config(DEFAULT_CONFIG) + + if __name__ == "__main__": APP.secret_key = "rascsi_is_awesome_insecure_secret_key" APP.config["SESSION_TYPE"] = "filesystem" APP.config["MAX_CONTENT_LENGTH"] = int(MAX_FILE_SIZE) - # Load the default configuration file, if found - if Path(f"{CFG_DIR}/{DEFAULT_CONFIG}").is_file(): - read_config(DEFAULT_CONFIG) - - if len(argv) > 1: - PORT = int(argv[1]) - else: - PORT = 8080 + parser = argparse.ArgumentParser(description="RaSCSI Web Interface arguments") + parser.add_argument( + "--port", + type=int, + default=8080, + action="store", + help="Port number the web server will run on", + ) + parser.add_argument( + "--password", + type=str, + default="", + action="store", + help="Token password string for authenticating with RaSCSI", + ) + args = parser.parse_args() + APP.config["TOKEN"] = args.password import bjoern print("Serving rascsi-web...") - bjoern.run(APP, "0.0.0.0", PORT) + bjoern.run(APP, "0.0.0.0", args.port)