diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e7a125a1b..bce325608 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -41,10 +41,12 @@ jobs: pylint --exit-zero amplipi --generated-members "signal.Signals,GPIO.*" pylint -E amplipi --generated-members "signal.Signals,GPIO.*" pylint -E streams + pylint -E scripts/increment_auto_off.py - name: Lint with mypy, static type checker run: | pip install mypy mypy amplipi/ --ignore-missing-imports + mypy scripts/increment_auto_off.py --ignore-missing-imports - name: Test mock using pytest # rpi cannot be tested directly due to hardware... run: | pip install pytest pytest-cov diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fe83e74..0be98e620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * Add error handling on browser page for instances where the selected stream isn't browsable * Add scrollbars to tall modals * Change how events are handled with Modals to reduce accidental closures + * Rename Updater to Admin Panel + * Add "Admin Settings" tab to the Updater/Admin Panel + * Add toggleable option to persist system logs to Admin Settings + * Move "Set password" page to be accessible via Admin Settings tab * System * Make update process properly report errors diff --git a/amplipi/app.py b/amplipi/app.py index 45ae9762b..f89d9d59a 100644 --- a/amplipi/app.py +++ b/amplipi/app.py @@ -226,7 +226,7 @@ def shutdown(): """ # preemptively save the state (just in case the shutdown procedure doesn't invoke a save) get_ctrl().save() - # start the shutdown process and returning immediately (hopeully before the shutdown process begins) + # start the shutdown process and returning immediately (hopefully before the shutdown process begins) Popen('sleep 1 && sudo systemctl poweroff', shell=True) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index c8236020e..30175b464 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -222,7 +222,7 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not except Exception as exc: logger.exception("Error setting is_streamer flag: {exc}") - # determine if we're in LMS mode, based on a file + # determine if we're in LMS mode, based on a file existing lms_mode_path = Path(defaults.USER_CONFIG_DIR, 'lms_mode') if lms_mode_path.exists(): logger.info("lms mode") diff --git a/amplipi/updater/asgi.py b/amplipi/updater/asgi.py index e5ad73285..b96b7b5e2 100644 --- a/amplipi/updater/asgi.py +++ b/amplipi/updater/asgi.py @@ -36,6 +36,8 @@ import shutil import asyncio +import configparser + # web framework import requests from fastapi import FastAPI, Request, File, UploadFile, Depends, APIRouter, Response @@ -48,7 +50,8 @@ # pylint: disable=no-name-in-module from pydantic import BaseModel -from ..auth import CookieOrParamAPIKey, router as auth_router, set_password_hash, unset_password_hash, NotAuthenticatedException, not_authenticated_exception_handler, create_access_key +from ..auth import CookieOrParamAPIKey, router as auth_router, set_password_hash, unset_password_hash,\ + NotAuthenticatedException, not_authenticated_exception_handler, create_access_key app = FastAPI() router = APIRouter(dependencies=[Depends(CookieOrParamAPIKey)]) @@ -62,6 +65,72 @@ sse_messages: queue.Queue = queue.Queue() +def validate_logging_ini(): + """Fallback in case the ini file or any individual header doesn't exist, set to default settings. Only really comes up during tests.""" + tmp = '/tmp/logging.ini.tmp' + ini = '/var/log/logging.ini' + conf = configparser.ConfigParser(strict=False, allow_no_value=True) + + with open(tmp, "+w", encoding="utf-8") as file: + if os.path.exists(ini): + conf.read(ini) + else: + conf.read(file) + + if not conf.has_section("logging"): + conf.add_section("logging") + + if not conf.has_option("logging", "auto_off_delay"): + conf.set("logging", "auto_off_delay", "14") + auto_off_delay = conf.get("logging", "auto_off_delay", fallback="14") + if not auto_off_delay.isdigit() and bool(re.fullmatch(r'\d*\.\d+', auto_off_delay)): + # regex to check decimal state, this would lead to "123.45" and ".45" being true but not "123." + # Exclude anything that isdigit() as to not overwrite valid user settings + rounded = round(float(auto_off_delay)) if round(float(auto_off_delay)) > 0 else 1 # Avoid instances where it could be zero as to not set the "do not deactivate" setting + conf.set("logging", "auto_off_delay", str(rounded)) + elif not auto_off_delay.isdigit(): # Cannot be merged with the first check in an OR case as valid regex catches would be intercepted by that + conf.set("logging", "auto_off_delay", "14") + conf.write(file) + + subprocess.run(['sudo', 'mv', tmp, ini], check=True) + + +def validate_journald_conf(): + """Fallback in case the config file or any individual header doesn't exist, set to default settings""" + tmp = '/tmp/journald.conf.tmp' + conf = '/etc/systemd/journald.conf' + confparse = configparser.ConfigParser(strict=False, allow_no_value=True) + + with open(tmp, "+w", encoding="utf-8") as file: + if os.path.exists(conf): + confparse.read(conf) + else: + confparse.read(file) + + if not confparse.has_section("Journal"): + confparse.add_section("Journal") + + if not confparse.has_option("Journal", "Storage") or confparse.get("Journal", "Storage") not in ("volatile", "persistent"): + # While volatile is the system's default, having that value either not exist or be invalid points to there being a system error that caused it to get that way + # Set to persistent just in case + confparse.set("Journal", "Storage", "persistent") + + # Set everything else to default while preserving user settings + if not confparse.has_option("Journal", "SyncIntervalSec"): + confparse.set('Journal', 'SyncIntervalSec', '30s') + if not confparse.has_option("Journal", "SystemMaxUse"): + confparse.set('Journal', 'SystemMaxUse', '64M') + if not confparse.has_option("Journal", "RuntimeMaxUse"): + confparse.set('Journal', 'RuntimeMaxUse', '64M') + if not confparse.has_option("Journal", "ForwardToConsole"): + confparse.set('Journal', 'ForwardToConsole', 'no') + if not confparse.has_option("Journal", "ForwardToWall"): + confparse.set('Journal', 'ForwardToWall', 'no') + + confparse.write(file) + subprocess.run(['sudo', 'mv', tmp, conf], check=True) + + class ReleaseInfo(BaseModel): """ Software Release Information """ url: str @@ -95,6 +164,81 @@ class ReleaseInfo(BaseModel): logger.exception(f'Error loading identity file: {e}') +class Persist_Logs(BaseModel): + """Basemodel that consists of a bool and int, used to change different config files around the system via POST /settings/persist_logs""" + persist_logs: bool + auto_off_delay: int + + +@router.get("/settings/persist_logs") +def get_log_persist_state(): + """ + Checks /etc/systemd/journald.conf to find if the current storage setting is persistent and returns a bool + Note that returning false doesn't necessarily mean that logs are set to volatile, and could just mean that the config file is missing the line being read + """ + validate_journald_conf() + journalconf = configparser.ConfigParser(strict=False, allow_no_value=True) + journalconf.read('/etc/systemd/journald.conf') + + validate_logging_ini() + logconf = configparser.ConfigParser(strict=False, allow_no_value=True) + logconf.read('/var/log/logging.ini') + + # Fallback set is the default value of the Storage variable under the Journal header of the conf file + # Used when the variable cannot be read but the file itself can (implying that the variable is missing, and should be set to a default) + ret = Persist_Logs(persist_logs=journalconf.get("Journal", "Storage", fallback="volatile") == "persistent", auto_off_delay=logconf.get("logging", "auto_off_delay", fallback="14"),) + return ret + + +@router.post("/settings/persist_logs") +def toggle_persist_logs(data: Persist_Logs): + """Toggles the option within journald to save logs to memory or storage, and sets the length of time before that setting is reset to volatile""" + try: + # Just in case + validate_logging_ini() + validate_journald_conf() + + state = get_log_persist_state() + + if state.persist_logs != data.persist_logs: + journalconf = '/etc/systemd/journald.conf' + journaltmp = '/tmp/journald.conf.tmp' + journal = configparser.ConfigParser(strict=False, allow_no_value=True) + journal.read(journalconf) + + if not journal.has_section("Journal"): + journal.add_section('Journal') + + # goal_value is true if you wish to turn persistent logging on and false if you wish to turn it off + journal.set('Journal', 'Storage', 'persistent' if data.persist_logs else 'volatile') + + with open(journaltmp, 'w', encoding="utf-8") as conf_file: + journal.write(conf_file) + + subprocess.run(['sudo', 'mv', journaltmp, journalconf], check=True) + subprocess.run(['sudo', 'systemctl', 'restart', 'systemd-journald'], check=True) + logger.info(f"persist_logs set to {data.persist_logs}") + else: + logger.info("persist_logs unchanged") + + if state.auto_off_delay != data.auto_off_delay: + logconf = '/var/log/logging.ini' + logtmp = '/tmp/logging.ini.tmp' + log = configparser.ConfigParser(strict=False, allow_no_value=True) + log.read(logconf) + log.set('logging', 'auto_off_delay', f"{data.auto_off_delay}") # Accept auto_off_delay as an int for type checking, parse to str for configParser validity + with open(logtmp, 'w', encoding='utf-8') as file: + log.write(file) + subprocess.run(['sudo', 'mv', logtmp, logconf], check=True) + logger.info(f"auto_off_delay set to {data.auto_off_delay}") + + else: + logger.info("auto_off_delay unchanged") + except Exception as exc: + logger.exception(str(exc)) + return 500 + + @router.get('/update') def get_index(): """ Get the update website """ diff --git a/amplipi/updater/static/css/styles.css b/amplipi/updater/static/css/styles.css index 1e0d4ca2b..ed5b3c5e1 100644 --- a/amplipi/updater/static/css/styles.css +++ b/amplipi/updater/static/css/styles.css @@ -1,6 +1,20 @@ /* A couple styles to make the demo page look good */ + + .column-container { + display: flex; + flex-direction: column; + gap: 64px; +} + +.column-item { + display: flex; + flex-direction: column; + justify-content: space-around; + gap: 16px; +} + body { padding-bottom: 2rem; padding-top: 1rem; @@ -79,6 +93,15 @@ hr { background: #4a4a4a; } +.margin{ + margin-left: 0px; +} +@media (min-width: 768px) { + .margin{ + margin-left: 10px; + } +} + .fa-circle-notch { animation: fa-spin 1s infinite linear; } @@ -88,3 +111,41 @@ hr { background-color: #5a6268; border-color: #545b62; } + +.container { + padding-left: 1rem; + padding-right: 1rem; +} + + +.set-password-dialog { + color: #aaaaaa !important; +} + +/* lifted from https://www.w3schools.com/css/css_tooltip.asp */ + +.hover-label { + position: relative; + display: inline-block; + border-bottom: 1px dotted black; +} + +.hover-label .hover-tooltip{ + top: 100%; + left: 50%; + visibility: hidden; + width: 750px; + max-width: 70vw; + background-color: rgba(100, 100, 100, 1); + color: #ffffff; + text-align: center; + padding: 5px 0; + border-radius: 6px; + + position: absolute; + z-index: 1; +} + +.hover-label:hover .hover-tooltip{ + visibility: visible; +} diff --git a/amplipi/updater/static/index.html b/amplipi/updater/static/index.html index 7ef414a46..3412bd7f6 100644 --- a/amplipi/updater/static/index.html +++ b/amplipi/updater/static/index.html @@ -11,6 +11,7 @@ + @@ -28,22 +29,22 @@
- Update your AmpliPi device + Update & configure your AmpliPi device
tag.
$('#support-tunnel-detail').text(`Requesting a support tunnel. This may take up to 60s.
diff --git a/config/deactivate_persist_logs_crontab b/config/deactivate_persist_logs_crontab
new file mode 100755
index 000000000..306409fcc
--- /dev/null
+++ b/config/deactivate_persist_logs_crontab
@@ -0,0 +1,3 @@
+# If auto_off setting is active for persist logs, and persist logs is on, tick a counter down every night until it reaches 0 and then deactivate persist logs and reset the counter
+# Installed by AmpliPi
+0 0 * * * root /usr/local/bin/increment_auto_off.py
diff --git a/scripts/configure.py b/scripts/configure.py
index 548ecdaef..5f9067670 100755
--- a/scripts/configure.py
+++ b/scripts/configure.py
@@ -113,6 +113,18 @@
]
},
'logging': {
+ "copy": [
+ {
+ 'from': 'config/deactivate_persist_logs_crontab',
+ 'to': '/etc/cron.d/deactivate_persist_logs',
+ 'sudo': 'true',
+ },
+ {
+ 'from': 'scripts/increment_auto_off.py',
+ 'to': '/usr/local/bin/increment_auto_off.py',
+ 'sudo': 'true',
+ },
+ ],
'script': [
'echo "reconfiguring secondary logging utility rsyslog to only allow remote logging"',
f"echo '{RSYSLOG_CFG}' | sudo tee /etc/rsyslog.conf",
@@ -120,11 +132,15 @@
'sudo systemctl enable rsyslog.service',
'sudo systemctl restart rsyslog.service',
- 'echo "reconfiguring journald to only log to RAM"',
- r'echo -e "[Journal]\nStorage=volatile\nRuntimeMaxUse=64M\nForwardToConsole=no\nForwardToWall=no\n" | sudo tee /etc/systemd/journald.conf',
+ 'echo "If first deploy, reconfiguring journald to only log to RAM"',
+ r'[ ! -d /var/log/journal ] && echo -e "[Journal]\nStorage=volatile\nRuntimeMaxUse=64M\nForwardToConsole=no\nForwardToWall=no\n" | sudo tee /etc/systemd/journald.conf',
'sudo systemctl enable systemd-journald.service',
'sudo systemctl restart systemd-journald.service',
+ 'echo Handle dependencies for log persistence options',
+ 'sudo mkdir -p /var/log/journal',
+ 'sudo systemd-tmpfiles --create --prefix /var/log/journal',
+
'echo "enable socket to the journald server to allow easy access to system logs"',
'sudo systemctl enable systemd-journal-gatewayd.socket',
'sudo systemctl restart systemd-journal-gatewayd.socket',
diff --git a/scripts/increment_auto_off.py b/scripts/increment_auto_off.py
new file mode 100755
index 000000000..446229964
--- /dev/null
+++ b/scripts/increment_auto_off.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""A program that ticks a counter down until deactivating log_persistence"""
+
+import logging
+import sys
+import json
+import re
+
+import configparser
+import requests
+import subprocess
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+sh = logging.StreamHandler(sys.stdout)
+logger.addHandler(sh)
+
+state_persist: bool
+state_delay: int
+
+api_read_failure = False
+try:
+ response = requests.get('http://localhost:5001/settings/persist_logs', timeout=10)
+ state = response.json()
+
+ state_persist = state["persist_logs"]
+ state_delay = state["auto_off_delay"]
+except KeyError as exc:
+ logger.exception("Unable to read log persistence state through the api, attempting to read directly...")
+ api_read_failure = True
+except Exception as exc:
+ logger.exception(f"increment_auto_off.py has failed with the following exception:\n{exc}")
+ api_read_failure = True
+
+
+if api_read_failure:
+ try:
+ # If the api is inaccessible, assume it is down for whatever reason and extrapolate that there is no race condition to reading it directly
+ # This section is a copy of the /settings/persist_logs GET in asgi.py
+ journalconf = configparser.ConfigParser(strict=False, allow_no_value=True)
+ journalconf.read('/etc/systemd/journald.conf')
+
+ logconf = configparser.ConfigParser(strict=False, allow_no_value=True)
+ logconf.read('/var/log/logging.ini')
+
+ persist = journalconf.get("Journal", "Storage", fallback="volatile")
+ state_persist = True if persist not in ("volatile", "persistent") else persist == "persistent" # If value is somehow invalid, assume it's True just in case
+
+ if not logconf.has_option("logging", "auto_off_delay"):
+ state_delay = 14
+ auto_off = logconf.get("logging", "auto_off_delay", fallback="14")
+ if not auto_off.isdigit() and bool(re.fullmatch(r'\d*\.\d+', auto_off)):
+ # regex to check decimal state, this would lead to "123.45" and ".45" being true but not "123."
+ # Exclude anything that isdigit() as to not overwrite valid user settings
+ state_delay = round(float(auto_off)) if round(float(auto_off)) > 0 else 1 # Avoid instances where it could be zero as to not set the "do not deactivate" setting
+ else:
+ state_delay = int(auto_off) if auto_off.isdigit() else 14
+ except Exception as exc:
+ logger.exception(f"increment_auto_off.py has failed with the following exception:\n{exc}")
+
+
+if state_persist and state_delay is not None:
+ future_persist_state = state_delay != 1
+ delay = state_delay - 1
+ body = {
+ "persist_logs": future_persist_state,
+ "auto_off_delay": delay if future_persist_state else 14, # If no longer persisting, set to default
+ }
+
+ api_write_failure = False
+ try:
+ json_data = json.dumps(body)
+ response = requests.post(
+ url='http://localhost:5001/settings/persist_logs',
+ headers={'Content-Type': 'application/json'},
+ data=json_data,
+ timeout=10,
+ )
+ if response.ok:
+ if future_persist_state:
+ logging.info(f"Persist logs will be automatically turned off in {delay} day(s)")
+ else:
+ logging.info("Persist logs has been turned off automatically")
+ else:
+ logging.exception("Unable to update persist_logs state via api, attempting to write directly...")
+ api_write_failure = True
+ except Exception as exc:
+ logger.exception(f"increment_auto_off.py has failed with the following exception:\n{exc}")
+ api_write_failure = True
+
+ if api_write_failure:
+ # This section is a copy of the /settings/persist_logs POST in asgi.py
+ try:
+ conf = '/etc/systemd/journald.conf'
+ tmp = '/tmp/journald.conf.tmp'
+ journal = configparser.ConfigParser(strict=False, allow_no_value=True)
+ journal.read(conf)
+
+ if not journal.has_section("Journal"):
+ journal.add_section('Journal')
+
+ # goal_value is true if you wish to turn persistent logging on and false if you wish to turn it off
+ if body["persist_logs"]: # Set persist
+ journal.set('Journal', 'Storage', 'persistent')
+ else: # Reset config to default as seen in configure.py
+ journal.set('Journal', 'Storage', 'volatile')
+
+ with open(tmp, 'w', encoding="utf-8") as conf_file:
+ journal.write(conf_file)
+
+ subprocess.run(['sudo', 'mv', tmp, conf], check=True)
+ subprocess.run(['sudo', 'systemctl', 'restart', 'systemd-journald'], check=True)
+
+ if state_delay != body["auto_off_delay"]:
+ logini = '/var/log/logging.ini'
+ logtmp = '/tmp/logging.ini.tmp'
+ log = configparser.ConfigParser(strict=False, allow_no_value=True)
+ log.read(logini)
+ log.set('logging', 'auto_off_delay', f"{body['auto_off_delay']}") # Accept auto_off_delay as an int for type checking, parse to str for configParser validity
+ with open(logtmp, 'w', encoding='utf-8') as file:
+ log.write(file)
+ subprocess.run(['sudo', 'mv', logtmp, logini], check=True)
+ logging.info(f"Persist logs will be automatically turned off in {body['auto_off_delay']} day(s)")
+
+ except Exception as exc:
+ logger.exception(f"increment_auto_off.py has failed with the following exception:\n{exc}")
diff --git a/web/src/pages/Settings/Settings.jsx b/web/src/pages/Settings/Settings.jsx
index 5df59009f..d7c96de02 100644
--- a/web/src/pages/Settings/Settings.jsx
+++ b/web/src/pages/Settings/Settings.jsx
@@ -151,7 +151,7 @@ const Settings = ({ openPage }) => {