lager-cli 0.46.2__tar.gz → 0.47.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lager_cli-0.46.2/lager_cli.egg-info → lager_cli-0.47.0}/PKG-INFO +1 -1
- {lager_cli-0.46.2 → lager_cli-0.47.0}/__init__.py +1 -1
- {lager_cli-0.46.2 → lager_cli-0.47.0}/box_storage.py +11 -4
- lager_cli-0.47.0/commands/box/_net_preflight.py +440 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_shim_verbs.py +12 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/boxes.py +2 -2
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/config.py +536 -46
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/nets.py +57 -23
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/i2c.py +7 -1
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/spi.py +78 -7
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/defaults.py +5 -2
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/update.py +37 -3
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/webcam.py +140 -18
- {lager_cli-0.46.2 → lager_cli-0.47.0}/context/core.py +1 -1
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/setup_and_deploy_box.sh +14 -4
- {lager_cli-0.46.2 → lager_cli-0.47.0/lager_cli.egg-info}/PKG-INFO +1 -1
- {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/SOURCES.txt +2 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/LICENSE +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/MANIFEST.in +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/README.md +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/__main__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/address_utils.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/battery/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/battery/battery_tui.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/battery/websocket_client.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_device_identity.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_host_ops.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_mount_prep.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_pip_validation.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_ssh.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/box_group.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/diagnose.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/dut.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/hello.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/instruments.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/labjack_pins.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/lock.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/net_tui.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/ssh.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/ssh_setup.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/ble.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/blufi.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/router.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/uart.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/usb.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/websocket_client.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/wifi.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/arm.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/commands.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/net_cache.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/rtt_websocket_client.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/service_client.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/service_helper.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/tunnel.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/devenv.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/python.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/adc.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/dac.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/energy.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/gpi.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/gpo.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/logic.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/scope.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/thermocouple.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/watt.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/battery.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/battery_model_csv.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/eload.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/solar.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/supply.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/_host_cli.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/binaries.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/exec_.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/install.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/install_wheel.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/login.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/logs.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/uninstall.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/config.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/context/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/context/ci_detection.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/context/constants.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/context/error_handlers.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/context/session.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/group_usage.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/matchers.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/net_group.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/net_helpers.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/net_storage.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/param_types.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/ssh_utils.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/utils.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/version_skew.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/core/ws_diagnose.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/configure_docker_dns.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/configure_docker_dns.sh +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/convert_to_sparse_checkout.sh +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/setup_ssh_key.sh +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/security/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/security/secure_box_firewall.sh +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/errors.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/exceptions.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/gateway_auth.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/box_config.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/measurement/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/measurement/scope.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/measurement/scope_stream.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/power/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/power/enable_disable.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/dependency_links.txt +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/entry_points.txt +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/requires.txt +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/top_level.txt +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/main.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/pyproject.toml +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/safe_unpickle.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/setup.cfg +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/setup.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/simple_hdlc.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/sort_utils.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/status.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/supply/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/supply/supply_tui.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/supply/websocket_client.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/core/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/core/executor.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/main.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/completer.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/display.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/logo.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/repl.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/themes.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_box_lager_imports.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_box_storage.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_gateway_auth.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_gateway_callsites.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_host_cli.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_io_imports.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_update_gate.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/update_check.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16DNP.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16Kermit.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16SICK.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC32.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRCCCITT.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/__init__.py +0 -0
- {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/__init__.py +0 -0
|
@@ -327,6 +327,8 @@ def format_lock_user(user):
|
|
|
327
327
|
Recognized formats:
|
|
328
328
|
- ``<origin>:<id>:<email>`` -> just the email
|
|
329
329
|
(reservations written by other services, e.g. the web dashboard)
|
|
330
|
+
- ``<origin>:<id>:<name>:<email>`` -> just the name
|
|
331
|
+
(the same services, once they started recording a display name)
|
|
330
332
|
- ``ci:github:<repo>#<run>-<attempt>/<job>@<runner>:<pid>``
|
|
331
333
|
-> ``github <repo> run <run> job <job> on <runner>``
|
|
332
334
|
- ``ci:drone:<repo>#<build>:<pid>@<host>`` -> ``drone <repo> build <build>``
|
|
@@ -399,10 +401,15 @@ def format_lock_user(user):
|
|
|
399
401
|
return user
|
|
400
402
|
|
|
401
403
|
# Reservation holders written by other services (e.g. the web dashboard)
|
|
402
|
-
# look like ``<origin>:<id>:<email
|
|
403
|
-
#
|
|
404
|
-
#
|
|
404
|
+
# look like ``<origin>:<id>:<email>`` or, once those services record a
|
|
405
|
+
# display name, ``<origin>:<id>:<name>:<email>``; show the email or the
|
|
406
|
+
# name respectively. The ``ci:`` prefix is excluded above, and requiring
|
|
407
|
+
# an ``@`` in the last segment keeps genuinely unrecognized strings
|
|
408
|
+
# visible unchanged.
|
|
405
409
|
if not user.startswith('ci:'):
|
|
410
|
+
parts = user.split(':')
|
|
411
|
+
if len(parts) == 4 and '@' in parts[3] and parts[2] and '@' not in parts[2]:
|
|
412
|
+
return parts[2]
|
|
406
413
|
parts = user.split(':', 2)
|
|
407
414
|
if len(parts) == 3 and '@' in parts[2]:
|
|
408
415
|
return parts[2]
|
|
@@ -1701,7 +1708,7 @@ def box_not_found_error(box_name):
|
|
|
1701
1708
|
f"No box named '{box_name}'.",
|
|
1702
1709
|
cause=cause,
|
|
1703
1710
|
fixes=[
|
|
1704
|
-
f'Add it: lager boxes add --name {box_name} --ip [IP_ADDRESS]',
|
|
1711
|
+
f'Add it: lager boxes add --name {box_name} --ip [IP_ADDRESS] --user [USERNAME]',
|
|
1705
1712
|
'Or use an existing name / an IP address with --box.',
|
|
1706
1713
|
],
|
|
1707
1714
|
)
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# Copyright 2024-2026 Lager Data
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Pre-flight for switching a box's container to host networking.
|
|
6
|
+
|
|
7
|
+
Why this exists: `network-mode set host` + `apply` made a box unreachable to
|
|
8
|
+
the CLI, and the documented undo could not recover it, because the undo travels
|
|
9
|
+
the same route the switch had just closed. Recovery needed SSH and a hand-edit.
|
|
10
|
+
|
|
11
|
+
Two independent conditions do that, and both are invisible from the CLI host:
|
|
12
|
+
|
|
13
|
+
1. **The host firewall starts applying.** `secure_box_firewall.sh` allows the
|
|
14
|
+
Lager ports per interface -- `lo`, `docker0`, optionally `tailscale0`, and an
|
|
15
|
+
operator-named `--corporate-vpn IFACE` -- and then writes a blanket
|
|
16
|
+
`ufw deny <port>/tcp`. While the container published its ports, Docker's DNAT
|
|
17
|
+
put those packets through FORWARD and ufw's INPUT rules never saw them, so
|
|
18
|
+
none of that mattered. Host networking removes the DNAT, the deny rule starts
|
|
19
|
+
applying, and the operator's own route is cut. The script is run by
|
|
20
|
+
`lager install` by default, so most boxes carry these rules.
|
|
21
|
+
|
|
22
|
+
2. **A port-publishing gateway already owns the ports.** On a box fronted by a
|
|
23
|
+
gateway container, `/etc/lager/no_publish` is set and the gateway publishes
|
|
24
|
+
5000/8080/9000 and friends on the host. Under host networking the lager
|
|
25
|
+
container binds those directly, hits EADDRINUSE, and its control plane never
|
|
26
|
+
starts. Verified on a production box.
|
|
27
|
+
|
|
28
|
+
The interface that matters is the one carrying the operator's own traffic, not
|
|
29
|
+
a guessed "primary" NIC. The box can read it straight off the live SSH
|
|
30
|
+
connection, so this asks rather than guesses -- which also means it works for
|
|
31
|
+
any VPN, not just the one the firewall script happens to know by name.
|
|
32
|
+
|
|
33
|
+
Everything here refuses; nothing opens a port. Whether Lager's control plane
|
|
34
|
+
should be reachable from a LAN is a policy question that is tracked separately,
|
|
35
|
+
and a BLE change is not the place to answer it.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import ipaddress
|
|
40
|
+
import json
|
|
41
|
+
import re
|
|
42
|
+
from typing import List, Optional
|
|
43
|
+
|
|
44
|
+
# The two ports the CLI itself needs to keep talking to a box: 5000 is the
|
|
45
|
+
# Python execution service every box_config verb travels over, 9000 the net/HTTP
|
|
46
|
+
# API. Losing either strands the box. The other Lager ports matter to features
|
|
47
|
+
# rather than to reachability, and keeping this list short keeps the refusal
|
|
48
|
+
# specific -- the firewall script owns the full set.
|
|
49
|
+
CONTROL_PLANE_PORTS = (5000, 9000)
|
|
50
|
+
|
|
51
|
+
# Emitted on the box; prints one JSON object. python3 is always present (the
|
|
52
|
+
# container image is python-based and the host runs the deploy scripts with it).
|
|
53
|
+
# The container whose published ports are its own and therefore not a conflict.
|
|
54
|
+
LAGER_CONTAINER = "lager"
|
|
55
|
+
|
|
56
|
+
_PROBE_TEMPLATE = r"""
|
|
57
|
+
import json, os, shlex, subprocess
|
|
58
|
+
|
|
59
|
+
def sh(cmd, timeout=10):
|
|
60
|
+
try:
|
|
61
|
+
p = subprocess.run(cmd, shell=True, capture_output=True,
|
|
62
|
+
text=True, timeout=timeout)
|
|
63
|
+
return p.returncode, p.stdout, p.stderr
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return 1, "", str(e)
|
|
66
|
+
|
|
67
|
+
out = {}
|
|
68
|
+
|
|
69
|
+
# The interface carrying this very connection is the one that must stay open.
|
|
70
|
+
conn = os.environ.get("SSH_CONNECTION", "")
|
|
71
|
+
client = conn.split()[0] if conn else ""
|
|
72
|
+
out["client_ip"] = client
|
|
73
|
+
out["iface"] = ""
|
|
74
|
+
if client:
|
|
75
|
+
rc, so, _ = sh("ip route get %s" % client)
|
|
76
|
+
if rc == 0:
|
|
77
|
+
parts = so.split()
|
|
78
|
+
if "dev" in parts:
|
|
79
|
+
out["iface"] = parts[parts.index("dev") + 1]
|
|
80
|
+
|
|
81
|
+
# `command -v` searches the PATH a non-interactive SSH session gets, which can
|
|
82
|
+
# leave out /usr/sbin. A ufw missed here would read as "no firewall" and pass,
|
|
83
|
+
# so the standard locations are checked as well.
|
|
84
|
+
out["ufw_path"] = ""
|
|
85
|
+
rc, so, _ = sh("command -v ufw")
|
|
86
|
+
if rc == 0 and so.strip():
|
|
87
|
+
out["ufw_path"] = so.strip().splitlines()[0]
|
|
88
|
+
else:
|
|
89
|
+
for cand in ("/usr/sbin/ufw", "/sbin/ufw"):
|
|
90
|
+
if os.access(cand, os.X_OK):
|
|
91
|
+
out["ufw_path"] = cand
|
|
92
|
+
break
|
|
93
|
+
out["ufw_present"] = bool(out["ufw_path"])
|
|
94
|
+
out["user"] = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
|
|
95
|
+
out["ufw_active"] = False
|
|
96
|
+
out["ufw_readable"] = False
|
|
97
|
+
out["ufw_status"] = ""
|
|
98
|
+
if out["ufw_present"]:
|
|
99
|
+
# By path, so that a sudoers grant naming this path matches.
|
|
100
|
+
rc, so, _ = sh("sudo -n %s status 2>/dev/null" % shlex.quote(out["ufw_path"]))
|
|
101
|
+
if rc == 0 and so.strip():
|
|
102
|
+
out["ufw_readable"] = True
|
|
103
|
+
out["ufw_status"] = so
|
|
104
|
+
out["ufw_active"] = ("status: active" in so.lower())
|
|
105
|
+
|
|
106
|
+
out["no_publish"] = os.path.exists("/etc/lager/no_publish")
|
|
107
|
+
|
|
108
|
+
# Which container, if any, publishes each control port. A port the lager
|
|
109
|
+
# container publishes itself is NOT a conflict: apply stops the old container
|
|
110
|
+
# before starting the new one, so that port is about to be freed. Without this
|
|
111
|
+
# attribution the check fires on every normal box, because docker-proxy binds
|
|
112
|
+
# 5000 and 9000 there as a matter of course.
|
|
113
|
+
out["publishers"] = {}
|
|
114
|
+
for _p in __PORTS__:
|
|
115
|
+
rc, so, _ = sh("docker ps --filter publish=%d --format '{{.Names}}'" % _p)
|
|
116
|
+
out["publishers"][str(_p)] = (
|
|
117
|
+
[n.strip() for n in so.splitlines() if n.strip()] if rc == 0 else []
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Host ports already bound. Under host networking the container binds these
|
|
121
|
+
# itself, so anything already here is a collision.
|
|
122
|
+
bound = set()
|
|
123
|
+
rc, so, _ = sh("ss -ltnH 2>/dev/null || ss -ltn 2>/dev/null")
|
|
124
|
+
if rc == 0:
|
|
125
|
+
for line in so.splitlines():
|
|
126
|
+
cols = line.split()
|
|
127
|
+
if len(cols) < 4:
|
|
128
|
+
continue
|
|
129
|
+
local = cols[3]
|
|
130
|
+
if ":" not in local:
|
|
131
|
+
continue
|
|
132
|
+
port = local.rsplit(":", 1)[1]
|
|
133
|
+
if port.isdigit():
|
|
134
|
+
bound.add(int(port))
|
|
135
|
+
out["bound_ports"] = sorted(bound)
|
|
136
|
+
|
|
137
|
+
print(json.dumps(out))
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _probe_source(ports=CONTROL_PLANE_PORTS) -> str:
|
|
142
|
+
return _PROBE_TEMPLATE.replace("__PORTS__", repr(list(ports)))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PreflightResult:
|
|
146
|
+
"""What the box reported, plus the verdict.
|
|
147
|
+
|
|
148
|
+
`blockers` non-empty means refuse. `remediation` is what the operator can
|
|
149
|
+
run to clear a blocker; it is printed, never executed.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, probed: bool, data: Optional[dict] = None,
|
|
153
|
+
error: str = "") -> None:
|
|
154
|
+
self.probed = probed
|
|
155
|
+
self.data = data or {}
|
|
156
|
+
self.error = error
|
|
157
|
+
self.blockers: List[str] = []
|
|
158
|
+
self.remediation: List[str] = []
|
|
159
|
+
# Advice that is not a pasteable command. A gateway conflict cannot be
|
|
160
|
+
# fixed by a firewall rule, but the operator still needs to be told what
|
|
161
|
+
# their options are -- a blocker with no way forward is what pushes
|
|
162
|
+
# people onto the override flag.
|
|
163
|
+
self.notes: List[str] = []
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def ok(self) -> bool:
|
|
167
|
+
return not self.blockers
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def iface(self) -> str:
|
|
171
|
+
return self.data.get("iface") or ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def probe(box_ip: str, *, runner=None, timeout: int = 30,
|
|
175
|
+
ports=CONTROL_PLANE_PORTS) -> PreflightResult:
|
|
176
|
+
"""Gather the box-side facts. Never raises."""
|
|
177
|
+
from ._ssh import default_ssh_runner
|
|
178
|
+
|
|
179
|
+
run = runner or default_ssh_runner
|
|
180
|
+
try:
|
|
181
|
+
rc, stdout, stderr = run(box_ip, "python3 -",
|
|
182
|
+
stdin=_probe_source(ports), timeout=timeout)
|
|
183
|
+
except Exception as e: # transport blew up in a way the runner didn't map
|
|
184
|
+
return PreflightResult(False, error=str(e))
|
|
185
|
+
if rc != 0:
|
|
186
|
+
return PreflightResult(False, error=(stderr or stdout or "").strip()[:300])
|
|
187
|
+
try:
|
|
188
|
+
return PreflightResult(True, data=json.loads(stdout.strip().splitlines()[-1]))
|
|
189
|
+
except Exception:
|
|
190
|
+
return PreflightResult(False, error=f"unparseable probe output: {stdout!r}"[:300])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_UFW_ACTIONS = ("ALLOW", "DENY", "REJECT", "LIMIT")
|
|
194
|
+
# A ufw port spec: `5000`, `5000/tcp`, `8081:8090/tcp`, `80,443/tcp`.
|
|
195
|
+
_PORT_SPEC = re.compile(r"^\d+(?:[:,]\d+)*(?:/(?:tcp|udp))?$")
|
|
196
|
+
# What a ufw path may look like before it is printed into a sudoers line.
|
|
197
|
+
_SAFE_PATH = re.compile(r"^/[A-Za-z0-9_./-]+$")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _parse_ufw_rule(line: str):
|
|
201
|
+
"""Split one `ufw status` line into (to, action, direction, from) tokens.
|
|
202
|
+
|
|
203
|
+
None for the header, blank lines and anything else that is not a rule. The
|
|
204
|
+
rule's comment is dropped first: it is free text and can contain any word.
|
|
205
|
+
"""
|
|
206
|
+
tokens = line.split("#", 1)[0].split()
|
|
207
|
+
for i, token in enumerate(tokens):
|
|
208
|
+
if token in _UFW_ACTIONS:
|
|
209
|
+
rest = tokens[i + 1:]
|
|
210
|
+
direction = "IN"
|
|
211
|
+
if rest and rest[0] in ("IN", "OUT", "FWD"):
|
|
212
|
+
direction, rest = rest[0], rest[1:]
|
|
213
|
+
return tokens[:i], token, direction, rest
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _spec_covers(spec: str, port: int) -> bool:
|
|
218
|
+
"""True if a port spec covers TCP `port`. A `/udp` rule does not."""
|
|
219
|
+
ports, _, proto = spec.partition("/")
|
|
220
|
+
if proto and proto != "tcp":
|
|
221
|
+
return False
|
|
222
|
+
for part in ports.split(","):
|
|
223
|
+
lo, _, hi = part.partition(":")
|
|
224
|
+
if int(lo) <= port <= int(hi or lo):
|
|
225
|
+
return True
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _to_covers(to: List[str], port: int) -> Optional[bool]:
|
|
230
|
+
"""Whether a rule's To column covers TCP `port`.
|
|
231
|
+
|
|
232
|
+
None when it also names a destination address, whose reach this does not
|
|
233
|
+
try to place.
|
|
234
|
+
"""
|
|
235
|
+
if to == ["Anywhere"]:
|
|
236
|
+
return True
|
|
237
|
+
specs = [t for t in to if _PORT_SPEC.match(t)]
|
|
238
|
+
if specs and not any(_spec_covers(s, port) for s in specs):
|
|
239
|
+
return False
|
|
240
|
+
if len(specs) != len(to):
|
|
241
|
+
return None
|
|
242
|
+
return bool(specs)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _source_admits(frm: List[str], client_ip: str) -> Optional[bool]:
|
|
246
|
+
"""Whether a rule's From column covers the operator's address.
|
|
247
|
+
|
|
248
|
+
None when that cannot be told: no client address, or a form this does not
|
|
249
|
+
parse, such as a source port.
|
|
250
|
+
"""
|
|
251
|
+
if frm == ["Anywhere"]:
|
|
252
|
+
return True
|
|
253
|
+
if len(frm) != 1 or not client_ip:
|
|
254
|
+
return None
|
|
255
|
+
try:
|
|
256
|
+
return (ipaddress.ip_address(client_ip)
|
|
257
|
+
in ipaddress.ip_network(frm[0], strict=False))
|
|
258
|
+
except ValueError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _port_allowed_on(status_text: str, port: int, iface: str, *,
|
|
263
|
+
client_ip: str = "") -> bool:
|
|
264
|
+
"""True if ufw admits inbound TCP `port` arriving on `iface`.
|
|
265
|
+
|
|
266
|
+
ufw applies the first rule that matches, so this reads the rules in the
|
|
267
|
+
order `ufw status` lists them and lets the first one that applies decide.
|
|
268
|
+
An allow listed after a deny for the same port never takes effect. That is
|
|
269
|
+
exactly the shape a plain `ufw allow` leaves behind secure_box_firewall.sh's
|
|
270
|
+
blanket `deny <port>/tcp`, and counting it would pass a box that the switch
|
|
271
|
+
then cuts off.
|
|
272
|
+
|
|
273
|
+
Where a rule's reach is uncertain the answer leans to "not admitted": an
|
|
274
|
+
allow limited to a source or destination this cannot place does not count,
|
|
275
|
+
while a deny limited the same way still decides. A wrong "admitted" costs a
|
|
276
|
+
box the operator can no longer reach. When no rule applies, ufw's default
|
|
277
|
+
incoming policy decides, and the firewall script sets that to deny.
|
|
278
|
+
"""
|
|
279
|
+
v6 = ":" in client_ip
|
|
280
|
+
for line in status_text.splitlines():
|
|
281
|
+
rule = _parse_ufw_rule(line)
|
|
282
|
+
if rule is None:
|
|
283
|
+
continue
|
|
284
|
+
to, action, direction, frm = rule
|
|
285
|
+
if direction != "IN" or ("(v6)" in to) != v6:
|
|
286
|
+
continue
|
|
287
|
+
to = [t for t in to if t != "(v6)"]
|
|
288
|
+
frm = [t for t in frm if t != "(v6)"]
|
|
289
|
+
if "on" in to:
|
|
290
|
+
at = to.index("on")
|
|
291
|
+
if to[at + 1:] != [iface]:
|
|
292
|
+
continue
|
|
293
|
+
to = to[:at]
|
|
294
|
+
covers = _to_covers(to, port)
|
|
295
|
+
if covers is False:
|
|
296
|
+
continue
|
|
297
|
+
admits = _source_admits(frm, client_ip)
|
|
298
|
+
if action in ("ALLOW", "LIMIT"):
|
|
299
|
+
if covers and admits:
|
|
300
|
+
return True
|
|
301
|
+
continue
|
|
302
|
+
if admits is False:
|
|
303
|
+
continue
|
|
304
|
+
return False
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _ufw_status_grant(data: dict):
|
|
309
|
+
"""(ufw path, the one sudoers line that lets this check read the firewall).
|
|
310
|
+
|
|
311
|
+
Only the line. Which file it goes in is the operator's decision: Lager
|
|
312
|
+
writes, and names, only the sudoers files it owns -- see the ownership
|
|
313
|
+
contract in _host_ops.py.
|
|
314
|
+
"""
|
|
315
|
+
from ._host_ops import is_valid_unix_username
|
|
316
|
+
|
|
317
|
+
ufw = data.get("ufw_path") or ""
|
|
318
|
+
if not _SAFE_PATH.match(ufw):
|
|
319
|
+
ufw = "/usr/sbin/ufw"
|
|
320
|
+
user = data.get("user") or ""
|
|
321
|
+
if not is_valid_unix_username(user):
|
|
322
|
+
user = "<box-user>"
|
|
323
|
+
return ufw, f"{user} ALL=(root) NOPASSWD: {ufw} status"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def evaluate(result: PreflightResult, *, ports=CONTROL_PLANE_PORTS) -> PreflightResult:
|
|
327
|
+
"""Fill in blockers/remediation. Returns the same object for chaining."""
|
|
328
|
+
if not result.probed:
|
|
329
|
+
result.blockers.append(
|
|
330
|
+
"Cannot check the box's firewall and port state over SSH "
|
|
331
|
+
f"({result.error or 'no detail'}). Host networking can make the box "
|
|
332
|
+
"unreachable, and this check is what makes that recoverable."
|
|
333
|
+
)
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
d = result.data
|
|
337
|
+
|
|
338
|
+
# 1. Something other than the lager container already owns the ports.
|
|
339
|
+
bound = set(d.get("bound_ports") or [])
|
|
340
|
+
publishers = d.get("publishers") or {}
|
|
341
|
+
collisions = []
|
|
342
|
+
for port in ports:
|
|
343
|
+
if port not in bound:
|
|
344
|
+
continue
|
|
345
|
+
owners = publishers.get(str(port)) or []
|
|
346
|
+
if owners and all(o == LAGER_CONTAINER for o in owners):
|
|
347
|
+
# Our own published port. `apply` stops this container before
|
|
348
|
+
# starting the replacement, so the port is about to be freed --
|
|
349
|
+
# treating it as taken refuses every normal box.
|
|
350
|
+
continue
|
|
351
|
+
collisions.append(port)
|
|
352
|
+
|
|
353
|
+
if d.get("no_publish") or collisions:
|
|
354
|
+
detail = (
|
|
355
|
+
"ports held by something else: "
|
|
356
|
+
+ ", ".join(str(p) for p in collisions)
|
|
357
|
+
if collisions else "/etc/lager/no_publish is set"
|
|
358
|
+
)
|
|
359
|
+
result.blockers.append(
|
|
360
|
+
"This box is fronted by a port-publishing gateway "
|
|
361
|
+
f"({detail}). On host networking the lager container binds those "
|
|
362
|
+
"ports itself and will fail to start. Host mode and a publishing "
|
|
363
|
+
"gateway cannot both own the same ports."
|
|
364
|
+
)
|
|
365
|
+
result.notes.append(
|
|
366
|
+
"No firewall rule fixes this. Either leave this box on lagernet, or "
|
|
367
|
+
"move the gateway off the Lager ports first."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# 2. The firewall cuts the operator's own route.
|
|
371
|
+
if d.get("ufw_present") and not d.get("ufw_readable"):
|
|
372
|
+
# Lager grants no `ufw status`, and adding one to the sudoers file it
|
|
373
|
+
# owns would cost every box an interactive sudo prompt on its next
|
|
374
|
+
# update (see update.py's box-config sudoers step). So the refusal names
|
|
375
|
+
# the one grant this check needs, for a file of the operator's own.
|
|
376
|
+
ufw, rule = _ufw_status_grant(d)
|
|
377
|
+
result.blockers.append(
|
|
378
|
+
"ufw is installed but its status could not be read: "
|
|
379
|
+
f"`sudo -n {ufw} status` needs a password on this box, so this check "
|
|
380
|
+
"cannot tell whether the switch cuts your route to the box."
|
|
381
|
+
)
|
|
382
|
+
result.notes.append(
|
|
383
|
+
"Lager does not grant this. To let the check read the firewall, add "
|
|
384
|
+
"the line below to a sudoers file of your own under /etc/sudoers.d/, "
|
|
385
|
+
"using `sudo visudo -f <file>` so a syntax error cannot break sudo. "
|
|
386
|
+
"The line allows that one read-only command and nothing else. Lager "
|
|
387
|
+
"changes only the sudoers files it owns, so the grant survives "
|
|
388
|
+
"lager install and lager update."
|
|
389
|
+
)
|
|
390
|
+
result.notes.append(rule)
|
|
391
|
+
elif d.get("ufw_active"):
|
|
392
|
+
iface = result.iface
|
|
393
|
+
if not iface:
|
|
394
|
+
result.blockers.append(
|
|
395
|
+
"ufw is active but the interface carrying this connection could "
|
|
396
|
+
"not be determined, so the ports that must stay open are unknown."
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
status = d.get("ufw_status") or ""
|
|
400
|
+
client_ip = d.get("client_ip") or ""
|
|
401
|
+
missing = [p for p in ports
|
|
402
|
+
if not _port_allowed_on(status, p, iface, client_ip=client_ip)]
|
|
403
|
+
if missing:
|
|
404
|
+
result.blockers.append(
|
|
405
|
+
f"ufw is active and does not admit {', '.join(str(p) for p in missing)} "
|
|
406
|
+
f"on {iface}, the interface you reach this box on. Published "
|
|
407
|
+
"ports bypass ufw; host networking does not, so applying this "
|
|
408
|
+
"cuts your own route to the box."
|
|
409
|
+
)
|
|
410
|
+
# `insert 1`, not a plain allow. secure_box_firewall.sh writes
|
|
411
|
+
# its per-interface allows first and a blanket `deny <port>/tcp`
|
|
412
|
+
# last, and ufw is first-match -- so an APPENDED allow lands
|
|
413
|
+
# after that deny and does nothing. Measured: identical rule
|
|
414
|
+
# content at position 14 blocked, at position 10 reachable.
|
|
415
|
+
#
|
|
416
|
+
# Position 1 is ahead of the deny whatever else the box has, so
|
|
417
|
+
# these survive being pasted in any order; several inserts at 1
|
|
418
|
+
# just stack, all still ahead of the deny. The delete clears any
|
|
419
|
+
# earlier appended attempt, because ufw dedupes and would
|
|
420
|
+
# otherwise answer `Skipping inserting existing rule`.
|
|
421
|
+
result.remediation = []
|
|
422
|
+
for p in missing:
|
|
423
|
+
result.remediation.append(
|
|
424
|
+
f"sudo ufw --force delete allow in on {iface} "
|
|
425
|
+
f"to any port {p} proto tcp"
|
|
426
|
+
)
|
|
427
|
+
result.remediation.append(
|
|
428
|
+
f"sudo ufw insert 1 allow in on {iface} "
|
|
429
|
+
f"to any port {p} proto tcp "
|
|
430
|
+
f"comment 'Lager service ({iface})'"
|
|
431
|
+
)
|
|
432
|
+
result.notes.append(
|
|
433
|
+
"The delete lines report 'Could not delete non-existent rule' "
|
|
434
|
+
"when there was no earlier attempt. That is expected."
|
|
435
|
+
)
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def check(box_ip: str, *, runner=None, ports=CONTROL_PLANE_PORTS) -> PreflightResult:
|
|
440
|
+
return evaluate(probe(box_ip, runner=runner), ports=ports)
|
|
@@ -55,3 +55,15 @@ NPM_ADD = "npm-add"
|
|
|
55
55
|
NPM_REMOVE = "npm-remove"
|
|
56
56
|
UDEV_ADD = "udev-add"
|
|
57
57
|
UDEV_REMOVE = "udev-remove"
|
|
58
|
+
|
|
59
|
+
# Container network mode. Unset deletes the key rather than writing the default
|
|
60
|
+
# back, so a config returns to byte-for-byte what it was before the mode was
|
|
61
|
+
# ever set -- and therefore to the same hash.
|
|
62
|
+
# Read verb, listed with the mutating pair because it shares their vocabulary.
|
|
63
|
+
# It is also the deploy discriminator: the generic `show` verb predates this
|
|
64
|
+
# feature and answers identically on a box that has it and one that does not,
|
|
65
|
+
# so asking through `show` cannot tell you whether the box understands the
|
|
66
|
+
# setting at all.
|
|
67
|
+
NETWORK_MODE_SHOW = "network-mode-show"
|
|
68
|
+
NETWORK_MODE_SET = "network-mode-set"
|
|
69
|
+
NETWORK_MODE_UNSET = "network-mode-unset"
|
|
@@ -50,7 +50,7 @@ def _list_boxes_live(port=9000, timeout=5):
|
|
|
50
50
|
saved_boxes = list_boxes()
|
|
51
51
|
|
|
52
52
|
if not saved_boxes:
|
|
53
|
-
click.echo("No boxes found. Add boxes with: lager boxes add --name [NAME] --ip [IP_ADDRESS]")
|
|
53
|
+
click.echo("No boxes found. Add boxes with: lager boxes add --name [NAME] --ip [IP_ADDRESS] --user [USERNAME]")
|
|
54
54
|
return
|
|
55
55
|
|
|
56
56
|
box_word = 'box' if len(saved_boxes) == 1 else 'boxes'
|
|
@@ -487,7 +487,7 @@ def delete(ctx, name, yes):
|
|
|
487
487
|
if available:
|
|
488
488
|
click.echo(f"Available boxes: {', '.join(available)}", err=True)
|
|
489
489
|
else:
|
|
490
|
-
click.echo("No boxes configured. Add one with: lager boxes add --name [NAME] --ip [IP_ADDRESS]", err=True)
|
|
490
|
+
click.echo("No boxes configured. Add one with: lager boxes add --name [NAME] --ip [IP_ADDRESS] --user [USERNAME]", err=True)
|
|
491
491
|
ctx.exit(1)
|
|
492
492
|
|
|
493
493
|
box_info = existing_boxes[name]
|