76 lines
3.8 KiB
Python
76 lines
3.8 KiB
Python
"""/logs: the last lines of the journal of the router's units and of the files web.log_files lists."""
|
|
|
|
import os
|
|
import subprocess
|
|
|
|
import vpnrouter
|
|
|
|
#: The units whose journal /logs/journal shows, besides the whole journal: the package's own and the services it runs.
|
|
JOURNAL_UNITS = ('vpn-router-setup', 'vpn-router-web', 'vpn-router-metrics', 'vpn-router-pki', 'vpn-router-backup',
|
|
'vpn-router-caddy', 'vpn-router-manager', 'caddy', 'slapd', 'nslcd', 'postgresql', 'strongswan',
|
|
'wg-quick@wg0', 'openvpn-server@vpn-router', 'ssh', 'vpn-router-resolver', 'vpn-router-webdav', 'pdns')
|
|
|
|
#: The most lines /logs answers, the number it answers without lines, and the most bytes it reads from the end of a file.
|
|
LOG_LINES_MAX = 1000
|
|
|
|
LOG_LINES_DEFAULT = 200
|
|
|
|
LOG_BYTES_MAX = 1024 * 1024
|
|
|
|
|
|
def tail(path, count):
|
|
"""The last count lines of a file, read from at most LOG_BYTES_MAX bytes at its end, invalid UTF-8 replaced."""
|
|
with open(path, 'rb') as handle:
|
|
size = handle.seek(0, os.SEEK_END)
|
|
start = max(0, size - LOG_BYTES_MAX)
|
|
handle.seek(start)
|
|
lines = handle.read().decode('utf-8', 'replace').splitlines()
|
|
# A read that starts inside the file starts inside a line, which is left out.
|
|
return (lines[1:] if start and lines else lines)[-count:]
|
|
|
|
|
|
def logs(method, path, query):
|
|
"""Answer a /logs request: the HTTP status and the JSON document.
|
|
|
|
GET /logs {"units": JOURNAL_UNITS, "files": web.log_files, "max_lines": LOG_LINES_MAX}
|
|
GET /logs/journal {"lines": [...]}: the last lines of the journal, of the unit unit= names, one of
|
|
JOURNAL_UNITS, or of the whole journal without it
|
|
GET /logs/file {"lines": [...]}: the last lines of the file path= names, one of web.log_files
|
|
|
|
lines= is the number of lines, 1 to LOG_LINES_MAX, LOG_LINES_DEFAULT without it.
|
|
"""
|
|
if method not in ('GET', 'HEAD'):
|
|
return 405, {'error': f'{method} is not allowed on /logs'}
|
|
try:
|
|
files = [item.strip() for item in vpnrouter.load_config()['web']['log_files'] if item.strip()]
|
|
except SystemExit as exc:
|
|
return 500, {'error': str(exc.code)}
|
|
if path == '/logs':
|
|
return 200, {'units': list(JOURNAL_UNITS), 'files': files, 'max_lines': LOG_LINES_MAX}
|
|
text = query.get('lines', [str(LOG_LINES_DEFAULT)])[0]
|
|
if not text.isdigit() or not 1 <= int(text) <= LOG_LINES_MAX:
|
|
return 400, {'error': f'lines must be a whole number from 1 to {LOG_LINES_MAX}'}
|
|
count = int(text)
|
|
if path == '/logs/journal':
|
|
unit = query.get('unit', [''])[0]
|
|
if unit and unit not in JOURNAL_UNITS:
|
|
return 404, {'error': f'{unit} is not one of the units whose journal /logs shows'}
|
|
try:
|
|
result = subprocess.run(['journalctl', '--no-pager', '--quiet', '--output=short-iso', f'--lines={count}',
|
|
*([f'--unit={unit}.service'] if unit else [])], capture_output=True, text=True,
|
|
errors='replace', timeout=30)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
return 500, {'error': f'journalctl cannot be run: {exc}'}
|
|
if result.returncode != 0:
|
|
return 500, {'error': result.stderr.strip() or f'journalctl ended with status {result.returncode}'}
|
|
return 200, {'lines': result.stdout.splitlines()[-count:]}
|
|
if path == '/logs/file':
|
|
name = query.get('path', [''])[0]
|
|
if name not in files:
|
|
return 404, {'error': f'{name!r} is not one of web.log_files'}
|
|
try:
|
|
return 200, {'lines': tail(name, count)}
|
|
except OSError as exc:
|
|
return (404 if isinstance(exc, FileNotFoundError) else 500), {'error': f'{name} cannot be read: {exc.strerror}'}
|
|
return 404, {'error': f'no route {method} {path}'}
|