Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions app/host_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@
'''
_MAX_PACKAGE_COUNT = 100000

# The restart marker test of MAINTENANCE_COMMAND alone: no package manager,
# no sudo, no writes. It runs only where that marker was seen before, so its
# absence means a restart on any system. See recheck_reboot_hint.
REBOOT_HINT_COMMAND = r'''LC_ALL=C
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL PATH
if [ -e /var/run/reboot-required ] || [ -e /run/reboot-required ]; then printf 'reboot=yes\n';
else printf 'reboot=no\n'; fi
:
'''
# Matches the overview, which stops showing facts older than a week.
REBOOT_HINT_MAX_AGE_SECONDS = 7 * 24 * 3600


def parse_maintenance(text, *, max_bytes=1024):
"""Return {'reboot_required', 'updates', 'security_updates'}; unknown values stay None."""
Expand Down Expand Up @@ -273,6 +286,80 @@ def services_needed(profile, now=None):
return not 0 <= current - checked < SERVICE_REFRESH_SECONDS


def reboot_hint_needs_check(profile, now=None):
"""True only while the overview shows a restart hint from a check the user ran."""
facts = profile.get('detected_maintenance') if isinstance(profile, dict) else None
if not isinstance(facts, dict) or facts.get('reboot_required') is not True or facts.get('recheck_stopped'):
return False
checked = facts.get('checked_at')
if isinstance(checked, bool) or not isinstance(checked, (int, float)):
return False
age = (time.time() if now is None else now) - checked
return -24 * 3600 <= age <= REBOOT_HINT_MAX_AGE_SECONDS


def parse_reboot_hint(text, *, max_bytes=64):
"""Accept exactly the one line REBOOT_HINT_COMMAND prints; banners or extra lines are not an answer."""
if not isinstance(text, str) or len(text.encode('utf-8')) > max_bytes:
raise ValueError('Invalid restart marker answer')
answer = {'reboot=yes': True, 'reboot=no': False}.get(text.strip())
if answer is None:
raise ValueError('Invalid restart marker answer')
return answer


def collect_reboot_hint(session_id, *, cancel_event=None):
from .session_insights import collect_linux_stats
return collect_linux_stats(
session_id, timeout=1.5, max_bytes=64,
command=REBOOT_HINT_COMMAND, parser=parse_reboot_hint,
allow_partial=False, cancel_event=cancel_event,
)


def recheck_reboot_hint(session_id, user_id, publish, *, cancel_event=None):
"""Reread only the restart marker on login while a restart hint is shown.

Hosts without a hint are never asked anything. The hint exists only after
the user ran or scheduled the Updates and restart status check, which read
the same marker over the same login, so this repeats a read the user
already chose. Exactly "reboot=no" lowers the hint, exactly "reboot=yes"
keeps it for the next login. Any other answer may come from a forced
command (authorized_keys command=, ForceCommand) that the server runs in
place of ours, so the recheck stops for this hint until the next full
check. All failures leave the PTY alone.
"""
from . import ssh_manager, profile_manager
try:
with ssh_manager.sessions_lock:
session = ssh_manager.sessions.get(session_id)
if not session or session.get('user_id') != user_id or not session.get('connected'):
return
expected = endpoint(session)
profile_id = session.get('profile_id')
if expected is None or not profile_id or (cancel_event is not None and cancel_event.is_set()):
return
profile = profile_manager.get_profile(user_id, profile_id)
if not profile or endpoint(profile) != expected or not reboot_hint_needs_check(profile):
return
checked_at = profile['detected_maintenance']['checked_at']
answer, error = collect_reboot_hint(session_id, cancel_event=cancel_event)
# Busy means another sample held the session; nothing reached the host.
if error == 'busy' or answer is True or (cancel_event is not None and cancel_event.is_set()):
return
with ssh_manager.sessions_lock:
current = ssh_manager.sessions.get(session_id)
if current is not session or not current.get('connected') or endpoint(current) != expected:
return
if answer is False and not error:
if profile_manager.settle_reboot_hint(user_id, profile_id, expected, checked_at, cleared=True):
publish({'profile_id': profile_id})
return
profile_manager.settle_reboot_hint(user_id, profile_id, expected, checked_at, cleared=False)
except Exception:
return


def detect_connected_platform(session_id, user_id, publish, *, cancel_event=None):
"""Optional once-per-host work; all failures leave the PTY alone."""
from . import ssh_manager, profile_manager
Expand Down
28 changes: 28 additions & 0 deletions app/profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,34 @@ def cache_detected_facts(user_id, profile_id, expected_endpoint, *, platform=Non
return False


@user_operation
def settle_reboot_hint(user_id, profile_id, expected_endpoint, checked_at, *, cleared):
"""Lower a restart hint, or stop rechecking it, if it is still the one that was read.

Only the hint from the check stamped checked_at changes; a newer full
check replaces detected_maintenance and starts over. Update counts and
the check time stay as that check reported them.
"""
if expected_endpoint is None or isinstance(checked_at, bool) or not isinstance(checked_at, (int, float)):
return False
with storage_lock(f'command-config:{user_id}'):
with storage_lock(f'profiles:{user_id}'):
profiles, error = _load_profiles_for_write(user_id)
if error:
return False
for profile in profiles:
if profile.get('id') != profile_id or endpoint(profile) != expected_endpoint:
continue
facts = profile.get('detected_maintenance')
if (not isinstance(facts, dict) or facts.get('reboot_required') is not True
or facts.get('recheck_stopped') or facts.get('checked_at') != checked_at):
return False
change = {'reboot_required': False} if cleared else {'recheck_stopped': True}
profile['detected_maintenance'] = {**facts, **change}
return save_profiles(user_id, profiles, previous_count=len(profiles))
return False


@user_operation
def cache_detected_platform(user_id, profile_id, expected_endpoint, platform):
"""Merge only platform facts into the current owned profile under its lock."""
Expand Down
9 changes: 9 additions & 0 deletions app/socket_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,15 @@ def connect_ssh(lifecycle_cancel_event, credentials=credential_box):
)
except Exception:
pass
try:
from .host_platform import recheck_reboot_hint
recheck_reboot_hint(
session_id, current_user.id,
lambda payload: socketio.emit('host_maintenance', payload, to=request.sid),
cancel_event=cancellation,
)
except Exception:
pass
except StorageCorruptionError as error:
if not cancellation.is_set():
emit('ssh_error', _storage_error_payload(
Expand Down
2 changes: 1 addition & 1 deletion docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ For finished runs on several hosts, **Compare hosts** groups the hosts per step

### Keep restart and update status current

WebSSH never checks updates on its own. The **Updates and restart status** template reads whether a restart is required (`/var/run/reboot-required`, any Linux) and, on Debian and Ubuntu, how many updates and security updates the local package lists show (a simulated `apt-get -s upgrade`). It uses no sudo, refreshes nothing, takes no lock and writes no file, also as root: apt's planner log and binary caches are switched off. `apt-check` and `dnf` are not used because both write caches or logs even when only reading, so Fedora and other systems report only the restart marker. The host controls this output, so the overview shows the values as reported and never acts on them. Counts lag behind the repositories until the host refreshes its lists. Run the template yourself or approve a schedule, for example daily. The overview takes the result only from that exact, unchanged step without sudo, with exit status 0 and untruncated output, and only for hosts whose address, port, user and route still match. When security updates are pending and Automation is enabled, the overview offers **Open upgrade workflow** for the Debian or Ubuntu hosts concerned; it opens the upgrade template as a draft with those hosts selected.
WebSSH never checks updates on its own. The **Updates and restart status** template reads whether a restart is required (`/var/run/reboot-required`, any Linux) and, on Debian and Ubuntu, how many updates and security updates the local package lists show (a simulated `apt-get -s upgrade`). It uses no sudo, refreshes nothing, takes no lock and writes no file, also as root: apt's planner log and binary caches are switched off. `apt-check` and `dnf` are not used because both write caches or logs even when only reading, so Fedora and other systems report only the restart marker. The host controls this output, so the overview shows the values as reported and never acts on them. Counts lag behind the repositories until the host refreshes its lists. Run the template yourself or approve a schedule, for example daily. The overview takes the result only from that exact, unchanged step without sudo, with exit status 0 and untruncated output, and only for hosts whose address, port, user and route still match. When security updates are pending and Automation is enabled, the overview offers **Open upgrade workflow** for the Debian or Ubuntu hosts concerned; it opens the upgrade template as a draft with those hosts selected. While the overview shows a restart hint for a saved host, each login to that host rereads only the restart marker (two existence tests, no sudo, no package manager, no writes). When the marker is gone, the hint disappears; the update counts stay until the template runs again. Any answer other than exactly `reboot=yes` or `reboot=no`, or none, keeps the hint and stops these rereads until the next full check, because a forced command (`command=` in `authorized_keys`, `ForceCommand`) may have answered in place of the test. Hosts without a restart hint are never asked. **Check restart status again** opens the template as a draft for the hosts concerned.

Expand a step in **Runs** to see its exit code, duration, execution reason and captured standard/error output. New immediate and scheduled runs retain bounded output; older records explicitly indicate that output was not recorded. Output becomes available when the step ends. A confirmed zero exit status means the command reported success, not that WebSSH independently verified the intended change.

Expand Down
5 changes: 5 additions & 0 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1696,6 +1696,11 @@
if (data.profile_id) ProfileManager.loadProfiles();
});

// The login recheck found the restart marker gone; reload the saved facts.
socket.on('host_maintenance', data => {
if (typeof data?.profile_id === 'string' && data.profile_id) ProfileManager.loadProfiles();
});


socket.on('ssh_connected', (data) => {
const requestId = typeof data?.client_request_id === 'string'
Expand Down
6 changes: 6 additions & 0 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,7 @@ const translations = {
"overviewWorkspace.chipUpdatesSecurity": "{count} updates, {security} security",
"overviewWorkspace.maintenanceChecked": "From the host's package cache, checked {time}",
"overviewWorkspace.openUpgrade": "Open upgrade workflow for {count} hosts",
"overviewWorkspace.openRecheck": "Check restart status again for {count} hosts",
"overviewWorkspace.jumpMissing": "Jump host missing for {name}",
"overviewWorkspace.runFailed": "Run failed: {name}",
"overviewWorkspace.scheduleReview": "Schedule needs review: {name}",
Expand Down Expand Up @@ -3215,6 +3216,7 @@ const translations = {
"overviewWorkspace.chipUpdatesSecurity": "{count} bản cập nhật, {security} bảo mật",
"overviewWorkspace.maintenanceChecked": "Theo bộ nhớ đệm gói của máy chủ, kiểm tra lúc {time}",
"overviewWorkspace.openUpgrade": "Mở quy trình nâng cấp cho {count} máy chủ",
"overviewWorkspace.openRecheck": "Kiểm tra lại trạng thái khởi động lại cho {count} máy chủ",
"overviewWorkspace.jumpMissing": "Thiếu máy chủ trung chuyển cho {name}",
"overviewWorkspace.runFailed": "Lần chạy thất bại: {name}",
"overviewWorkspace.scheduleReview": "Lịch cần xem lại: {name}",
Expand Down Expand Up @@ -5639,6 +5641,7 @@ const translations = {
"overviewWorkspace.chipUpdatesSecurity": "{count} Updates, {security} Sicherheit",
"overviewWorkspace.maintenanceChecked": "Laut Paket-Cache des Hosts, geprüft {time}",
"overviewWorkspace.openUpgrade": "Upgrade-Workflow für {count} Hosts öffnen",
"overviewWorkspace.openRecheck": "Neustart-Status für {count} Hosts erneut prüfen",
"overviewWorkspace.jumpMissing": "Jump Host fehlt für {name}",
"overviewWorkspace.runFailed": "Lauf fehlgeschlagen: {name}",
"overviewWorkspace.scheduleReview": "Zeitplan prüfen: {name}",
Expand Down Expand Up @@ -8058,6 +8061,7 @@ const translations = {
"overviewWorkspace.chipUpdatesSecurity": "{count} mises à jour, {security} de sécurité",
"overviewWorkspace.maintenanceChecked": "D'après le cache de paquets de l'hôte, vérifié le {time}",
"overviewWorkspace.openUpgrade": "Ouvrir le flux de mise à niveau pour {count} hôtes",
"overviewWorkspace.openRecheck": "Vérifier à nouveau l’état de redémarrage pour {count} hôtes",
"overviewWorkspace.jumpMissing": "Hôte de rebond manquant pour {name}",
"overviewWorkspace.runFailed": "Échec de l’exécution : {name}",
"overviewWorkspace.scheduleReview": "Planification à vérifier : {name}",
Expand Down Expand Up @@ -10477,6 +10481,7 @@ const translations = {
"overviewWorkspace.chipUpdatesSecurity": "{count} actualizaciones, {security} de seguridad",
"overviewWorkspace.maintenanceChecked": "Según la caché de paquetes del host, comprobado {time}",
"overviewWorkspace.openUpgrade": "Abrir el flujo de actualización para {count} hosts",
"overviewWorkspace.openRecheck": "Volver a comprobar el estado de reinicio de {count} hosts",
"overviewWorkspace.jumpMissing": "Falta el host de salto de {name}",
"overviewWorkspace.runFailed": "Ejecución fallida: {name}",
"overviewWorkspace.scheduleReview": "Revisa la programación: {name}",
Expand Down Expand Up @@ -12895,6 +12900,7 @@ const translations = {
"overviewWorkspace.chipUpdatesSecurity": "{count} 个更新,{security} 个安全更新",
"overviewWorkspace.maintenanceChecked": "来自主机的软件包缓存,检查于 {time}",
"overviewWorkspace.openUpgrade": "为 {count} 台主机打开升级工作流",
"overviewWorkspace.openRecheck": "重新检查 {count} 台主机的重启状态",
"overviewWorkspace.jumpMissing": "{name} 缺少跳板机",
"overviewWorkspace.runFailed": "运行失败:{name}",
"overviewWorkspace.scheduleReview": "计划需要检查:{name}",
Expand Down
9 changes: 9 additions & 0 deletions static/js/overview-workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,15 @@
open.append(icon('system_update_alt'));
entry.append(open);
}
// A restart hint only changes when the host is asked again; this opens that check as a draft.
if (item.kind === 'reboot' && win.WebSSHAutomation?.openTemplate && state.automationEnabled) {
const label = tw('openRecheck', 'Check restart status again for {count} hosts', {count: item.hostIds.length});
const recheck = button('od-dismiss', label, () => win.WebSSHAutomation.openTemplate('maintenance', item.hostIds));
recheck.title = label;
recheck.dataset.key = `recheck:${item.key}`;
recheck.append(icon('refresh'));
entry.append(recheck);
}
entry.append(dismiss);
return entry;
}));
Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/overview-filters.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,26 @@ test('pending security updates open the matching upgrade workflow as a draft wit
expect(writes).toEqual([]);
expect(await sshAttempts(page)).toEqual([]);
});

test('a restart hint offers to check the same hosts again, as a draft and without contacting them', async ({page}) => {
const {ids} = await openWithReadiness(page);
await expect.poll(() => page.evaluate(() => window.WebSSHAutomation?.getRunSnapshot?.().enabled === true)).toBe(true);
const affected = ids.slice(-2);
await page.evaluate(hostIds => {
const checked = Math.floor(Date.now() / 1000);
ProfileManager.profiles.forEach(profile => {
if (hostIds.includes(profile.id)) profile.detected_maintenance = {reboot_required: true, checked_at: checked};
});
window.dispatchEvent(new Event('profile-list-change'));
}, affected);
const writes = [];
page.on('request', request => { if (request.method() !== 'GET' && request.url().includes('/api/automation/')) writes.push(request.url()); });
const recheck = page.locator('#odAttention [data-key^="recheck:"]');
await expect(recheck).toHaveAttribute('aria-label', 'Check restart status again for 2 hosts');
await recheck.click();
await expect(page.locator('#runbookName')).toHaveValue('Updates and restart status');
await expect.poll(() => page.locator('#targetHosts input:checked').evaluateAll(inputs => inputs.map(input => input.value).sort()))
.toEqual([...affected].sort());
expect(writes).toEqual([]);
expect(await sshAttempts(page)).toEqual([]);
});
14 changes: 14 additions & 0 deletions tests/integration/test_automation_openssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,17 @@ def test_real_openssh_maintenance_probe_feeds_the_saved_host(peer):
assert _record_maintenance_facts(uid, saved, step, result, nullcontext)
stored = profile_manager.get_profile(uid, saved['id'])['detected_maintenance']
assert stored['reboot_required'] == facts['reboot_required'] and stored['checked_at'] > 0


def test_real_openssh_login_recheck_reads_the_restart_marker(peer):
"""The login recheck command runs unprivileged on Debian OpenSSH and answers yes or no."""
from app.automation_transport import execute_step
from app.host_platform import REBOOT_HINT_COMMAND, parse_reboot_hint
uid, profile = peer
trust(uid)
step = dict(name='Restart marker', command=REBOOT_HINT_COMMAND, timeout=10, sudo=False)
with connect_profile(uid, profile, PASSWORD, lambda: False) as transport:
result = execute_step(transport, step, lambda: False)
assert result['status'] == 'succeeded' and result['exit_code'] == 0, result
assert result['stderr'] == ''
assert parse_reboot_hint(result['stdout']) in (True, False)
Loading
Loading