lager-cli 0.32.4__tar.gz → 0.32.5__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.32.4/lager_cli.egg-info → lager_cli-0.32.5}/PKG-INFO +1 -1
- {lager_cli-0.32.4 → lager_cli-0.32.5}/__init__.py +1 -1
- {lager_cli-0.32.4 → lager_cli-0.32.5}/battery/websocket_client.py +30 -13
- {lager_cli-0.32.4 → lager_cli-0.32.5}/box_storage.py +67 -21
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/boxes.py +26 -1
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/config.py +7 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/diagnose.py +7 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/lock.py +7 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/uart.py +4 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/websocket_client.py +31 -17
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/service_client.py +11 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/update.py +7 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/context/session.py +10 -1
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/version_skew.py +9 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/ws_diagnose.py +18 -1
- {lager_cli-0.32.4 → lager_cli-0.32.5}/gateway_auth.py +75 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5/lager_cli.egg-info}/PKG-INFO +1 -1
- {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/SOURCES.txt +1 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/status.py +8 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/supply/websocket_client.py +30 -13
- {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_box_lager_imports.py +48 -48
- {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_gateway_auth.py +160 -0
- lager_cli-0.32.5/tests/test_gateway_callsites.py +177 -0
- lager_cli-0.32.5/tests/test_io_imports.py +266 -0
- lager_cli-0.32.4/tests/test_io_imports.py +0 -351
- {lager_cli-0.32.4 → lager_cli-0.32.5}/LICENSE +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/MANIFEST.in +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/README.md +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/__main__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/address_utils.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/battery/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/battery/battery_tui.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_host_ops.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_mount_prep.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_pip_validation.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_shim_verbs.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_ssh.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/box_group.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/dut.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/hello.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/instruments.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/labjack_pins.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/net_tui.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/nets.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/ssh.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/ssh_setup.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/ble.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/blufi.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/i2c.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/router.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/spi.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/usb.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/wifi.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/arm.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/commands.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/gdb.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/net_cache.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/service_helper.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/tunnel.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/devenv.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/python.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/adc.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/dac.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/energy.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/gpi.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/gpo.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/logic.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/scope.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/thermocouple.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/watt.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/battery.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/battery_model_csv.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/eload.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/solar.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/supply.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/binaries.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/defaults.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/exec_.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/install.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/install_wheel.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/login.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/logs.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/uninstall.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/webcam.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/config.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/context/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/context/ci_detection.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/context/constants.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/context/core.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/context/error_handlers.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/group_usage.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/matchers.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/net_group.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/net_helpers.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/net_storage.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/param_types.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/ssh_utils.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/core/utils.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/configure_docker_dns.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/configure_docker_dns.sh +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/convert_to_sparse_checkout.sh +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/setup_and_deploy_box.sh +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/setup_ssh_key.sh +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/security/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/security/secure_box_firewall.sh +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/construct_utils.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/exceptions.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/py3compat.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/utils.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/adapters.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/core.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/debug.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/macros.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/abbrevtable.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/aranges.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/callframe.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/compileunit.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/constants.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/descriptions.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/die.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/dwarf_expr.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/dwarfinfo.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/enums.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/lineprogram.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/locationlists.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/namelut.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/ranges.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/structs.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/constants.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/decoder.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/ehabiinfo.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/structs.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/constants.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/descriptions.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/dynamic.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/elffile.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/enums.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/gnuversions.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/hash.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/notes.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/relocation.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/sections.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/segments.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/structs.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/errors.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/exceptions.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/box_config.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/measurement/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/measurement/scope.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/measurement/scope_stream.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/power/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/power/enable_disable.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/dependency_links.txt +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/entry_points.txt +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/requires.txt +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/top_level.txt +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/main.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/pyproject.toml +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/safe_unpickle.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/setup.cfg +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/setup.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/simple_hdlc.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/sort_utils.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/supply/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/supply/supply_tui.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/core/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/core/executor.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/main.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/completer.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/display.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/logo.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/repl.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/themes.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_box_storage.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_update_gate.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/update_check.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16DNP.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16Kermit.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16SICK.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC32.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRCCCITT.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/__init__.py +0 -0
- {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/__init__.py +0 -0
|
@@ -132,19 +132,36 @@ class BatteryWebSocketClient:
|
|
|
132
132
|
Returns:
|
|
133
133
|
True if connected successfully, False otherwise
|
|
134
134
|
"""
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
135
|
+
from ..gateway_auth import (
|
|
136
|
+
auth_headers_for_url, plain_error_text, ws_handshake_recovery,
|
|
137
|
+
)
|
|
138
|
+
headers = auth_headers_for_url(self.box_url)
|
|
139
|
+
for attempt in (0, 1):
|
|
140
|
+
try:
|
|
141
|
+
self.sio.connect(
|
|
142
|
+
self.box_url,
|
|
143
|
+
namespaces=['/battery'],
|
|
144
|
+
wait_timeout=timeout,
|
|
145
|
+
headers=headers
|
|
146
|
+
)
|
|
147
|
+
return True
|
|
148
|
+
except Exception as e:
|
|
149
|
+
# The handshake exception hides the HTTP response, so probe
|
|
150
|
+
# the box over HTTP: a gated box's first contact records the
|
|
151
|
+
# mapping and hands back the token for one retry; a genuine
|
|
152
|
+
# denial becomes the actionable message.
|
|
153
|
+
retry_headers, denial = ws_handshake_recovery(self.box_url, headers)
|
|
154
|
+
if denial is not None:
|
|
155
|
+
if self.on_error:
|
|
156
|
+
self.on_error(plain_error_text(denial))
|
|
157
|
+
return False
|
|
158
|
+
if attempt == 0 and retry_headers:
|
|
159
|
+
headers = retry_headers
|
|
160
|
+
continue
|
|
161
|
+
if self.on_error:
|
|
162
|
+
self.on_error(f"Could not connect to box at {self.box_url}: {str(e)}")
|
|
163
|
+
return False
|
|
164
|
+
return False
|
|
148
165
|
|
|
149
166
|
def start_monitoring(self) -> bool:
|
|
150
167
|
"""
|
|
@@ -583,43 +583,49 @@ def _gateway_kwargs(ip):
|
|
|
583
583
|
|
|
584
584
|
def _resend_with_auth(prepared, headers):
|
|
585
585
|
"""Re-send an already-prepared request with extra headers merged in.
|
|
586
|
-
Returns the new response, or None if the resend itself failed.
|
|
586
|
+
Returns the new response, or None if the resend itself failed.
|
|
587
|
+
|
|
588
|
+
Sent with ``stream=True`` so callers that stream the original request
|
|
589
|
+
(pip/python output) keep streaming on the retried response; non-streaming
|
|
590
|
+
callers read ``.json()``/``.text`` which consumes it identically."""
|
|
587
591
|
import requests
|
|
588
592
|
req = prepared.copy()
|
|
589
593
|
for key, value in headers.items():
|
|
590
594
|
req.headers[key] = value
|
|
591
595
|
try:
|
|
592
|
-
return requests.Session().send(req, timeout=30, stream=
|
|
596
|
+
return requests.Session().send(req, timeout=30, stream=True)
|
|
593
597
|
except requests.RequestException:
|
|
594
598
|
return None
|
|
595
599
|
|
|
596
600
|
|
|
597
|
-
def
|
|
598
|
-
"""
|
|
599
|
-
|
|
600
|
-
|
|
601
|
+
def _resolve_gateway(resp, ip):
|
|
602
|
+
"""Record-and-retry core shared by :func:`_check_gateway` and
|
|
603
|
+
:func:`check_gateway_status` — the single implementation of gateway
|
|
604
|
+
discovery. Returns ``(resp, denied)``:
|
|
601
605
|
|
|
606
|
+
- Non-gateway responses (no discovery header) pass through untouched
|
|
607
|
+
with ``denied=False`` — plain boxes are never affected.
|
|
602
608
|
- First contact — we sent no token (the box→auth-server link is only
|
|
603
609
|
learned from this very 401's discovery header) but we already hold a
|
|
604
610
|
session for that server: record the mapping, attach the token, and
|
|
605
|
-
retry the request once.
|
|
606
|
-
|
|
607
|
-
because only a gateway sends the discovery header.
|
|
608
|
-
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
Callers should adopt the return value: ``resp = _check_gateway(resp, ip)``.
|
|
611
|
+
retry the request once. On success the retried, authenticated response
|
|
612
|
+
is returned with ``denied=False``. A plain box never receives the
|
|
613
|
+
token, because only a gateway sends the discovery header.
|
|
614
|
+
- Anything else is a genuine denial: the mapping is still recorded (so
|
|
615
|
+
the next attempt authenticates) and ``denied=True`` tells the caller
|
|
616
|
+
to handle ``resp`` as a gateway denial.
|
|
612
617
|
"""
|
|
613
618
|
from .gateway_auth import (
|
|
614
|
-
DISCOVERY_HEADER,
|
|
615
|
-
record_box_auth_server, auth_headers_for_box,
|
|
619
|
+
DISCOVERY_HEADER, record_box_auth_server, auth_headers_for_box,
|
|
616
620
|
)
|
|
617
|
-
|
|
618
|
-
|
|
621
|
+
# getattr: tolerate response-shaped fakes without a headers attribute.
|
|
622
|
+
resp_headers = getattr(resp, 'headers', None) or {}
|
|
623
|
+
if not (resp.status_code in (401, 403, 503) and DISCOVERY_HEADER in resp_headers):
|
|
624
|
+
return resp, False
|
|
619
625
|
|
|
626
|
+
record_box_auth_server(ip, resp_headers[DISCOVERY_HEADER])
|
|
620
627
|
sent_auth = 'Authorization' in getattr(resp.request, 'headers', {})
|
|
621
628
|
if resp.status_code == 401 and not sent_auth:
|
|
622
|
-
record_box_auth_server(ip, resp.headers[DISCOVERY_HEADER])
|
|
623
629
|
headers = auth_headers_for_box(ip)
|
|
624
630
|
if headers:
|
|
625
631
|
retried = _resend_with_auth(resp.request, headers)
|
|
@@ -627,13 +633,47 @@ def _check_gateway(resp, ip):
|
|
|
627
633
|
gated = (retried.status_code in (401, 403, 503)
|
|
628
634
|
and DISCOVERY_HEADER in retried.headers)
|
|
629
635
|
if not gated:
|
|
630
|
-
return retried
|
|
631
|
-
resp = retried
|
|
636
|
+
return retried, False # transparently authenticated
|
|
637
|
+
resp = retried # still refused — report on the retry
|
|
638
|
+
|
|
639
|
+
return resp, True
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _check_gateway(resp, ip):
|
|
643
|
+
"""Resolve a gateway response, returning the response the caller should use.
|
|
644
|
+
|
|
645
|
+
On a plain (un-gated) box this is a passthrough. On a gated box the
|
|
646
|
+
first contact is retried transparently (see :func:`_resolve_gateway`);
|
|
647
|
+
genuine denials (revoked session, no access grant, auth server down)
|
|
648
|
+
raise the actionable `lager login` / "ask your admin" error as before.
|
|
632
649
|
|
|
633
|
-
|
|
650
|
+
Callers should adopt the return value: ``resp = _check_gateway(resp, ip)``.
|
|
651
|
+
"""
|
|
652
|
+
from .gateway_auth import handle_gateway_denial
|
|
653
|
+
resp, denied = _resolve_gateway(resp, ip)
|
|
654
|
+
if denied:
|
|
655
|
+
handle_gateway_denial(resp, ip) # raises the actionable error
|
|
634
656
|
return resp
|
|
635
657
|
|
|
636
658
|
|
|
659
|
+
def check_gateway_status(resp, ip):
|
|
660
|
+
"""Non-raising variant of :func:`_check_gateway` for fan-out and
|
|
661
|
+
fail-open callers (`lager boxes`, health polls) that must not abort on a
|
|
662
|
+
single box's denial.
|
|
663
|
+
|
|
664
|
+
Performs the same record-and-retry as ``_check_gateway``. Returns
|
|
665
|
+
``(resp, label)``: ``label`` is None when the caller should simply use
|
|
666
|
+
``resp`` (plain box, or the retry authenticated transparently), else a
|
|
667
|
+
short user-facing verdict — 'sign-in required', 'session rejected',
|
|
668
|
+
'no access', or 'auth server down'.
|
|
669
|
+
"""
|
|
670
|
+
from .gateway_auth import denial_label
|
|
671
|
+
resp, denied = _resolve_gateway(resp, ip)
|
|
672
|
+
if not denied:
|
|
673
|
+
return resp, None
|
|
674
|
+
return resp, denial_label(resp)
|
|
675
|
+
|
|
676
|
+
|
|
637
677
|
def _lock_url(ip, suffix=''):
|
|
638
678
|
# Lock state is shared box-wide; both the :5000 and :9000 servers expose
|
|
639
679
|
# it via lager.lock_state. The CLI talks to :9000 (the primary HTTP API).
|
|
@@ -807,6 +847,9 @@ def release_box_lock(ip, holder, *, quiet=True):
|
|
|
807
847
|
)
|
|
808
848
|
return False
|
|
809
849
|
|
|
850
|
+
# Non-raising gateway check (this function promises never to raise);
|
|
851
|
+
# a denial falls through to the failure warning below.
|
|
852
|
+
resp, _gate_verdict = check_gateway_status(resp, ip)
|
|
810
853
|
if resp.status_code == 200:
|
|
811
854
|
return True
|
|
812
855
|
if not quiet:
|
|
@@ -848,6 +891,9 @@ def heartbeat_box_lock(ip, holder, *, quiet=True):
|
|
|
848
891
|
fg='yellow', err=True,
|
|
849
892
|
)
|
|
850
893
|
return False
|
|
894
|
+
# Non-raising gateway check; a denial reads as a failed heartbeat, which
|
|
895
|
+
# callers already treat as "carry on" (server TTL is authoritative).
|
|
896
|
+
resp, _gate_verdict = check_gateway_status(resp, ip)
|
|
851
897
|
return resp.status_code == 200
|
|
852
898
|
|
|
853
899
|
|
|
@@ -47,6 +47,7 @@ def _list_boxes_live(port=9000, timeout=5):
|
|
|
47
47
|
|
|
48
48
|
results = []
|
|
49
49
|
failed_count = 0
|
|
50
|
+
auth_failed_count = 0
|
|
50
51
|
needs_update_count = 0
|
|
51
52
|
newer_count = 0
|
|
52
53
|
|
|
@@ -67,8 +68,14 @@ def _list_boxes_live(port=9000, timeout=5):
|
|
|
67
68
|
busy_info = ''
|
|
68
69
|
try:
|
|
69
70
|
from ...gateway_auth import auth_headers_for_box
|
|
71
|
+
from ...box_storage import check_gateway_status
|
|
70
72
|
lock_resp = requests.get(f'http://{ip}:{port}/lock', timeout=3,
|
|
71
73
|
headers={'Cache-Control': 'no-cache', **auth_headers_for_box(ip)})
|
|
74
|
+
# Non-raising gateway check: one gated box must not abort the
|
|
75
|
+
# whole table. On first contact this records the box→auth-server
|
|
76
|
+
# mapping and retries with the stored token, so the /status call
|
|
77
|
+
# below authenticates normally (no second round trip).
|
|
78
|
+
lock_resp, _ = check_gateway_status(lock_resp, ip)
|
|
72
79
|
if lock_resp.status_code == 200:
|
|
73
80
|
lock_data = lock_resp.json()
|
|
74
81
|
if lock_data.get('locked'):
|
|
@@ -81,12 +88,18 @@ def _list_boxes_live(port=9000, timeout=5):
|
|
|
81
88
|
# It predates the newer capability fields, so even older box images
|
|
82
89
|
# answer it — unlike a brand-new endpoint would.
|
|
83
90
|
from ...gateway_auth import auth_headers_for_box
|
|
91
|
+
from ...box_storage import check_gateway_status
|
|
84
92
|
url = f'http://{ip}:{port}/status'
|
|
85
93
|
headers = {'Cache-Control': 'no-cache', 'Pragma': 'no-cache',
|
|
86
94
|
**auth_headers_for_box(ip)}
|
|
87
95
|
response = requests.get(url, timeout=timeout, headers=headers)
|
|
96
|
+
response, gate_verdict = check_gateway_status(response, ip)
|
|
88
97
|
|
|
89
|
-
if
|
|
98
|
+
if gate_verdict:
|
|
99
|
+
results.append((name, ip, user, '-', gate_verdict, locked_by, busy_info))
|
|
100
|
+
auth_failed_count += 1
|
|
101
|
+
|
|
102
|
+
elif response.status_code == 200:
|
|
90
103
|
try:
|
|
91
104
|
data = response.json()
|
|
92
105
|
box_version = data.get('version') or data.get('box_version')
|
|
@@ -205,6 +218,18 @@ def _list_boxes_live(port=9000, timeout=5):
|
|
|
205
218
|
if failed_count > 0:
|
|
206
219
|
box_word = 'box' if failed_count == 1 else 'boxes'
|
|
207
220
|
click.secho(f'{failed_count} {box_word} could not be reached', fg='red')
|
|
221
|
+
if auth_failed_count > 0:
|
|
222
|
+
from ...gateway_auth import auth_server_for_box
|
|
223
|
+
box_word = 'box needs' if auth_failed_count == 1 else 'boxes need'
|
|
224
|
+
click.secho(f'{auth_failed_count} {box_word} sign-in or an access grant', fg='red')
|
|
225
|
+
# The denial recorded each box's auth server, so we can say exactly
|
|
226
|
+
# where to sign in. Usually one server covers the whole fleet.
|
|
227
|
+
signin_urls = sorted({
|
|
228
|
+
url for r in results if r[4] in ('sign-in required', 'session rejected')
|
|
229
|
+
for url in [auth_server_for_box(r[1])] if url
|
|
230
|
+
})
|
|
231
|
+
for url in signin_urls:
|
|
232
|
+
click.secho(f' Sign in with: lager login {url}', fg='red')
|
|
208
233
|
|
|
209
234
|
root_locked = [r[0] for r in results if r[5] == 'root']
|
|
210
235
|
if root_locked:
|
|
@@ -1627,8 +1627,15 @@ def _box_api_responding(box_ip: str, *, timeout: float = 2.0) -> bool:
|
|
|
1627
1627
|
poll cheaply."""
|
|
1628
1628
|
try:
|
|
1629
1629
|
from ...gateway_auth import auth_headers_for_box
|
|
1630
|
+
from ...box_storage import check_gateway_status
|
|
1630
1631
|
r = requests.get(f"http://{box_ip}:{_BOX_API_PORT}/hello", timeout=timeout,
|
|
1631
1632
|
headers=auth_headers_for_box(box_ip))
|
|
1633
|
+
# Non-raising gateway check: first contact with a gated box records
|
|
1634
|
+
# the mapping and retries with the stored token so the probe sees the
|
|
1635
|
+
# real /hello. A genuine denial reads as not-responding (False) —
|
|
1636
|
+
# this probe sits inside the apply/restart poll loop, which must not
|
|
1637
|
+
# abort mid-restart; the flow's own requests surface auth errors.
|
|
1638
|
+
r, _gate_verdict = check_gateway_status(r, box_ip)
|
|
1632
1639
|
return r.status_code == 200
|
|
1633
1640
|
except Exception:
|
|
1634
1641
|
return False
|
|
@@ -30,6 +30,7 @@ import click
|
|
|
30
30
|
import requests
|
|
31
31
|
|
|
32
32
|
from ...box_storage import resolve_and_validate_box_with_name
|
|
33
|
+
from ...errors import LagerError
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
# Net-role choices for --type. Mirrors the role strings produced by
|
|
@@ -51,8 +52,10 @@ def _fetch_net_info(
|
|
|
51
52
|
that — otherwise the returned role wins."""
|
|
52
53
|
try:
|
|
53
54
|
from ...gateway_auth import auth_headers_for_box
|
|
55
|
+
from ...box_storage import _check_gateway
|
|
54
56
|
r = requests.get(f'http://{box_ip}:9000/nets/list', timeout=5,
|
|
55
57
|
headers=auth_headers_for_box(box_ip))
|
|
58
|
+
r = _check_gateway(r, box_ip)
|
|
56
59
|
r.raise_for_status()
|
|
57
60
|
nets = r.json()
|
|
58
61
|
# Older box images wrap the list: {"nets": [...]}. Unwrap so the
|
|
@@ -72,6 +75,10 @@ def _fetch_net_info(
|
|
|
72
75
|
f'Check network/Tailscale connectivity, then `lager hello --box {display_name}`.',
|
|
73
76
|
fg='red'), err=True)
|
|
74
77
|
return None, None
|
|
78
|
+
except LagerError:
|
|
79
|
+
# A gateway denial from _check_gateway is already actionable — let it
|
|
80
|
+
# render as-is rather than collapsing into the generic message below.
|
|
81
|
+
raise
|
|
75
82
|
except Exception as e:
|
|
76
83
|
click.echo(click.style(f'Could not fetch net list from box: {e}', fg='red'), err=True)
|
|
77
84
|
return None, None
|
|
@@ -35,6 +35,7 @@ def lock(ctx, box, lock_user):
|
|
|
35
35
|
# lock immune to the new heartbeat-driven auto-reap that ephemeral
|
|
36
36
|
# `lager python` locks participate in.
|
|
37
37
|
from ...gateway_auth import auth_headers_for_box
|
|
38
|
+
from ...box_storage import _check_gateway
|
|
38
39
|
resp = requests.post(
|
|
39
40
|
f'http://{ip}:9000/lock',
|
|
40
41
|
headers=auth_headers_for_box(ip),
|
|
@@ -45,6 +46,7 @@ def lock(ctx, box, lock_user):
|
|
|
45
46
|
},
|
|
46
47
|
timeout=5,
|
|
47
48
|
)
|
|
49
|
+
resp = _check_gateway(resp, ip)
|
|
48
50
|
except requests.exceptions.RequestException as e:
|
|
49
51
|
click.secho(f"Error: Could not reach box '{display_name}': {e}", fg='red', err=True)
|
|
50
52
|
ctx.exit(1)
|
|
@@ -80,12 +82,17 @@ def unlock(ctx, box, force):
|
|
|
80
82
|
|
|
81
83
|
try:
|
|
82
84
|
from ...gateway_auth import auth_headers_for_box
|
|
85
|
+
from ...box_storage import _check_gateway
|
|
83
86
|
resp = requests.post(
|
|
84
87
|
f'http://{ip}:9000/unlock',
|
|
85
88
|
headers=auth_headers_for_box(ip),
|
|
86
89
|
json={'user': user, 'force': force},
|
|
87
90
|
timeout=5,
|
|
88
91
|
)
|
|
92
|
+
# Gateway denials raise here with the actionable sign-in error; the
|
|
93
|
+
# 403 branch below keeps meaning "locked by another user" only (a
|
|
94
|
+
# plain box's application 403 has no discovery header).
|
|
95
|
+
resp = _check_gateway(resp, ip)
|
|
89
96
|
except requests.exceptions.RequestException as e:
|
|
90
97
|
click.secho(f"Error: Could not reach box '{display_name}': {e}", fg='red', err=True)
|
|
91
98
|
ctx.exit(1)
|
|
@@ -100,8 +100,12 @@ def _run_query_instruments(ctx: click.Context, box_ip: str) -> list[dict]:
|
|
|
100
100
|
"""
|
|
101
101
|
try:
|
|
102
102
|
from ...gateway_auth import auth_headers_for_box
|
|
103
|
+
from ...box_storage import _check_gateway
|
|
103
104
|
resp = requests.get(f'http://{box_ip}:9000/instruments/list', timeout=15,
|
|
104
105
|
headers=auth_headers_for_box(box_ip))
|
|
106
|
+
# A gateway denial raises the actionable LagerError (not caught
|
|
107
|
+
# below): an auth problem must not be reported as "no instruments".
|
|
108
|
+
resp = _check_gateway(resp, box_ip)
|
|
105
109
|
if resp.status_code == 200:
|
|
106
110
|
data = resp.json()
|
|
107
111
|
if isinstance(data, list):
|
|
@@ -260,23 +260,37 @@ class UARTWebSocketClient:
|
|
|
260
260
|
self._setup_terminal()
|
|
261
261
|
|
|
262
262
|
# Connect to WebSocket
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
263
|
+
from ...gateway_auth import auth_headers_for_url, ws_handshake_recovery
|
|
264
|
+
headers = auth_headers_for_url(self.box_url)
|
|
265
|
+
for attempt in (0, 1):
|
|
266
|
+
try:
|
|
267
|
+
self.sio.connect(
|
|
268
|
+
self.box_url,
|
|
269
|
+
namespaces=['/uart'],
|
|
270
|
+
wait_timeout=10,
|
|
271
|
+
headers=headers
|
|
272
|
+
)
|
|
273
|
+
break
|
|
274
|
+
except Exception as e:
|
|
275
|
+
# The handshake exception hides the HTTP response, so
|
|
276
|
+
# probe the box over HTTP: a gated box's first contact
|
|
277
|
+
# records the mapping and hands back the token for one
|
|
278
|
+
# retry; a genuine denial shows the actionable error.
|
|
279
|
+
# Preserve the return-code contract (don't sys.exit /
|
|
280
|
+
# .die here): the caller relies on the return value and
|
|
281
|
+
# the finally-block terminal restore below.
|
|
282
|
+
retry_headers, denial = ws_handshake_recovery(self.box_url, headers)
|
|
283
|
+
if denial is not None:
|
|
284
|
+
denial.show()
|
|
285
|
+
return 1
|
|
286
|
+
if attempt == 0 and retry_headers:
|
|
287
|
+
headers = retry_headers
|
|
288
|
+
continue
|
|
289
|
+
from ...errors import connection_error
|
|
290
|
+
from urllib.parse import urlparse
|
|
291
|
+
host = urlparse(self.box_url).hostname or self.box_url
|
|
292
|
+
connection_error(e, host=host).show()
|
|
293
|
+
return 1
|
|
280
294
|
|
|
281
295
|
# Start UART session
|
|
282
296
|
self.sio.emit('start_uart', {
|
|
@@ -44,6 +44,17 @@ class DebugServiceClient:
|
|
|
44
44
|
'User-Agent': 'lager-cli/1.0',
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
if not ssh_tunnel:
|
|
48
|
+
# Direct connection may cross an authenticating gateway: attach
|
|
49
|
+
# the bearer token when this box is known to be gated, and turn
|
|
50
|
+
# gateway denials into actionable errors instead of raw 401s.
|
|
51
|
+
# No-op for plain boxes (the hook only fires on the discovery
|
|
52
|
+
# header, and no token is sent to an unknown box). Tunneled
|
|
53
|
+
# connections terminate at 127.0.0.1 and never see the gateway.
|
|
54
|
+
from ....gateway_auth import auth_headers_for_box, gateway_response_hook
|
|
55
|
+
self.session.headers.update(auth_headers_for_box(box_host))
|
|
56
|
+
self.session.hooks['response'] = [gateway_response_hook(box_host)]
|
|
57
|
+
|
|
47
58
|
def health_check(self) -> Dict[str, Any]:
|
|
48
59
|
"""Check if service is healthy."""
|
|
49
60
|
response = self.session.get(f'{self.base_url}/health', timeout=5)
|
|
@@ -74,8 +74,15 @@ def wait_for_box_ready(box_ip, *, timeout_s=60, initial_delay_s=2):
|
|
|
74
74
|
for port in sorted(pending):
|
|
75
75
|
try:
|
|
76
76
|
from ...gateway_auth import auth_headers_for_box
|
|
77
|
+
from ...box_storage import check_gateway_status
|
|
77
78
|
r = requests.get(f'http://{box_ip}:{port}/health', timeout=3,
|
|
78
79
|
headers=auth_headers_for_box(box_ip))
|
|
80
|
+
# Non-raising gateway check: first contact with a gated box
|
|
81
|
+
# records the mapping and retries with the stored token so a
|
|
82
|
+
# healthy gated box reads as ready. A denial reads as
|
|
83
|
+
# not-ready and falls to the timeout path rather than
|
|
84
|
+
# aborting the update's readiness poll.
|
|
85
|
+
r, _gate_verdict = check_gateway_status(r, box_ip)
|
|
79
86
|
if r.status_code == 200:
|
|
80
87
|
pending.discard(port)
|
|
81
88
|
except requests.exceptions.RequestException:
|
|
@@ -358,8 +358,10 @@ class DirectIPSession:
|
|
|
358
358
|
|
|
359
359
|
try:
|
|
360
360
|
from ..gateway_auth import auth_headers_for_box
|
|
361
|
+
from ..box_storage import _check_gateway
|
|
361
362
|
response = requests.post(url, json=payload, timeout=5,
|
|
362
363
|
headers=auth_headers_for_box(self.ip_address))
|
|
364
|
+
response = _check_gateway(response, self.ip_address)
|
|
363
365
|
return response
|
|
364
366
|
except requests.exceptions.RequestException as e:
|
|
365
367
|
# Return a mock response with error
|
|
@@ -479,12 +481,19 @@ class DirectIPSession:
|
|
|
479
481
|
url = f'http://{self.ip_address}:5001/run/pip'
|
|
480
482
|
|
|
481
483
|
try:
|
|
484
|
+
from ..gateway_auth import auth_headers_for_box
|
|
485
|
+
from ..box_storage import _check_gateway
|
|
482
486
|
response = requests.post(
|
|
483
487
|
url,
|
|
484
488
|
json={'args': args},
|
|
485
489
|
stream=True,
|
|
486
|
-
timeout=600 # 10 minute timeout for pip operations
|
|
490
|
+
timeout=600, # 10 minute timeout for pip operations
|
|
491
|
+
headers=auth_headers_for_box(self.ip_address),
|
|
487
492
|
)
|
|
493
|
+
# Gateway discovery/denial handling; on a gated box's first
|
|
494
|
+
# contact the request is retried with the stored token so the
|
|
495
|
+
# caller streams the real pip output.
|
|
496
|
+
response = _check_gateway(response, self.ip_address)
|
|
488
497
|
|
|
489
498
|
# Check for HTTP errors
|
|
490
499
|
if response.status_code != 200:
|
|
@@ -61,8 +61,17 @@ def check_and_warn(box_ip: str, box_name: str | None = None) -> None:
|
|
|
61
61
|
# it's the same endpoint `lager box hello` uses. 1.5s timeout
|
|
62
62
|
# keeps the latency penalty bounded if the box is briefly slow.
|
|
63
63
|
from ..gateway_auth import auth_headers_for_box
|
|
64
|
+
from ..box_storage import check_gateway_status
|
|
64
65
|
r = requests.get(f'http://{box_ip}:9000/status', timeout=1.5,
|
|
65
66
|
headers=auth_headers_for_box(box_ip))
|
|
67
|
+
# Non-raising gateway check: on first contact with a gated box this
|
|
68
|
+
# records the mapping and retries with the stored token; a genuine
|
|
69
|
+
# denial just skips the warning (this module is fail-open — the
|
|
70
|
+
# command's own request will surface the actionable auth error).
|
|
71
|
+
r, gate_verdict = check_gateway_status(r, box_ip)
|
|
72
|
+
if gate_verdict:
|
|
73
|
+
logger.debug('version-skew check: gateway denied (%s)', gate_verdict)
|
|
74
|
+
return
|
|
66
75
|
if r.status_code == 404:
|
|
67
76
|
# The :9000 server answered but has no /status route — the box
|
|
68
77
|
# image predates the :9000 API surface this CLI requires. That is
|
|
@@ -37,9 +37,26 @@ def make_ws_failure_message(box_ip: str, original_error: str | Exception = '') -
|
|
|
37
37
|
# /health is a cheap, always-present endpoint on box_http_server
|
|
38
38
|
# (port 9000). 2s timeout matches the worst case of a healthy box
|
|
39
39
|
# under temporary load.
|
|
40
|
-
from ..gateway_auth import auth_headers_for_box
|
|
40
|
+
from ..gateway_auth import auth_headers_for_box, auth_server_for_box
|
|
41
|
+
from ..box_storage import check_gateway_status
|
|
41
42
|
r = requests.get(f'http://{box_ip}:9000/health', timeout=2.0,
|
|
42
43
|
headers=auth_headers_for_box(box_ip))
|
|
44
|
+
# Non-raising gateway check (this helper promises never to raise):
|
|
45
|
+
# first contact with a gated box records the mapping and retries with
|
|
46
|
+
# the stored token; a genuine denial becomes the actionable message.
|
|
47
|
+
r, gate_verdict = check_gateway_status(r, box_ip)
|
|
48
|
+
if gate_verdict:
|
|
49
|
+
url = auth_server_for_box(box_ip) or 'your auth server'
|
|
50
|
+
if gate_verdict in ('sign-in required', 'session rejected'):
|
|
51
|
+
action = f'sign in with `lager login {url}` and retry.'
|
|
52
|
+
elif gate_verdict == 'no access':
|
|
53
|
+
action = 'ask an org admin to grant you access to this box.'
|
|
54
|
+
else: # auth server down
|
|
55
|
+
action = 'its auth server is unreachable — try again shortly.'
|
|
56
|
+
return (
|
|
57
|
+
f'{base}{detail}.\n'
|
|
58
|
+
f'Action: the box refused the connection ({gate_verdict}) — {action}'
|
|
59
|
+
)
|
|
43
60
|
if r.status_code == 200:
|
|
44
61
|
return (
|
|
45
62
|
f'{base}{detail}.\n'
|
|
@@ -359,6 +359,36 @@ def handle_gateway_denial(response, box_ip):
|
|
|
359
359
|
)
|
|
360
360
|
|
|
361
361
|
|
|
362
|
+
def denial_label(response):
|
|
363
|
+
"""Short, render-friendly label for a gateway denial response.
|
|
364
|
+
|
|
365
|
+
For fan-out commands (`lager boxes`) that show one row per box and must
|
|
366
|
+
not abort the whole table on a single gated box. The full actionable
|
|
367
|
+
message stays in :func:`handle_gateway_denial`; this is the compact
|
|
368
|
+
verdict a table cell can hold.
|
|
369
|
+
"""
|
|
370
|
+
if response.status_code == 403:
|
|
371
|
+
return 'no access'
|
|
372
|
+
if response.status_code == 503:
|
|
373
|
+
return 'auth server down'
|
|
374
|
+
if 'Authorization' in getattr(response.request, 'headers', {}):
|
|
375
|
+
return 'session rejected'
|
|
376
|
+
return 'sign-in required'
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def plain_error_text(err):
|
|
380
|
+
"""A LagerError flattened to one unstyled line.
|
|
381
|
+
|
|
382
|
+
For error callbacks that render inside a TUI, where the ANSI codes
|
|
383
|
+
from ``LagerError.format_message()`` would show up literally.
|
|
384
|
+
"""
|
|
385
|
+
parts = [err.problem]
|
|
386
|
+
if err.cause:
|
|
387
|
+
parts.append(err.cause)
|
|
388
|
+
parts.extend(err.fixes)
|
|
389
|
+
return ' '.join(parts)
|
|
390
|
+
|
|
391
|
+
|
|
362
392
|
def gateway_response_hook(box_ip):
|
|
363
393
|
"""requests response hook that intercepts gateway denials."""
|
|
364
394
|
def hook(response, *_args, **_kwargs):
|
|
@@ -374,3 +404,48 @@ def auth_headers_for_url(box_url):
|
|
|
374
404
|
if not host:
|
|
375
405
|
return {}
|
|
376
406
|
return auth_headers_for_box(host)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def ws_handshake_recovery(box_url, sent_headers=None):
|
|
410
|
+
"""After a failed WebSocket/SocketIO handshake, work out whether an
|
|
411
|
+
authenticating gateway refused it.
|
|
412
|
+
|
|
413
|
+
The SocketIO/engineio handshake exception does not expose the HTTP
|
|
414
|
+
response headers, so the discovery header can't be read from it. Instead,
|
|
415
|
+
probe ``/health`` on the same host/port carrying the same auth the
|
|
416
|
+
handshake did; a gateway answers every path identically, so the probe
|
|
417
|
+
mirrors what the handshake saw. A plain box answers the probe without
|
|
418
|
+
the discovery header and is left completely untouched.
|
|
419
|
+
|
|
420
|
+
Returns ``(headers, error)``:
|
|
421
|
+
- ``({'Authorization': ...}, None)`` — the box is gated, the mapping is
|
|
422
|
+
now recorded, and we hold a session the failed handshake didn't
|
|
423
|
+
carry: retry the handshake once with these headers.
|
|
424
|
+
- ``({}, LagerError)`` — genuine gateway denial; show the error, don't
|
|
425
|
+
retry.
|
|
426
|
+
- ``({}, None)`` — not a gateway problem; the original error stands.
|
|
427
|
+
"""
|
|
428
|
+
parsed = urlparse(box_url)
|
|
429
|
+
host = parsed.hostname
|
|
430
|
+
if not host:
|
|
431
|
+
return {}, None
|
|
432
|
+
port = parsed.port or 80
|
|
433
|
+
probe_headers = dict(sent_headers or {})
|
|
434
|
+
try:
|
|
435
|
+
resp = requests.get(f'http://{host}:{port}/health',
|
|
436
|
+
headers=probe_headers, timeout=5)
|
|
437
|
+
except requests.RequestException:
|
|
438
|
+
return {}, None
|
|
439
|
+
if not (resp.status_code in (401, 403, 503)
|
|
440
|
+
and DISCOVERY_HEADER in resp.headers):
|
|
441
|
+
return {}, None
|
|
442
|
+
record_box_auth_server(host, resp.headers[DISCOVERY_HEADER])
|
|
443
|
+
if resp.status_code == 401 and 'Authorization' not in probe_headers:
|
|
444
|
+
headers = auth_headers_for_box(host)
|
|
445
|
+
if headers:
|
|
446
|
+
return headers, None
|
|
447
|
+
try:
|
|
448
|
+
handle_gateway_denial(resp, host)
|
|
449
|
+
except LagerError as err:
|
|
450
|
+
return {}, err
|
|
451
|
+
return {}, None
|
|
@@ -19,6 +19,14 @@ import threading
|
|
|
19
19
|
import sys
|
|
20
20
|
import os
|
|
21
21
|
import select
|
|
22
|
+
|
|
23
|
+
# BaseExceptionGroup is a builtin only from 3.11. setup.py declares
|
|
24
|
+
# python_requires=">=3.10", and the nursery below catches it -- on 3.10 that
|
|
25
|
+
# except clause raises NameError and masks the real exception. trio pulls in
|
|
26
|
+
# the `exceptiongroup` backport on <3.11, so it is always importable here.
|
|
27
|
+
if sys.version_info < (3, 11): # pragma: no cover - version-dependent
|
|
28
|
+
from exceptiongroup import BaseExceptionGroup
|
|
29
|
+
|
|
22
30
|
from functools import partial
|
|
23
31
|
from bson import decode as bson_loads
|
|
24
32
|
import click
|