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.
Files changed (162) hide show
  1. {lager_cli-0.46.2/lager_cli.egg-info → lager_cli-0.47.0}/PKG-INFO +1 -1
  2. {lager_cli-0.46.2 → lager_cli-0.47.0}/__init__.py +1 -1
  3. {lager_cli-0.46.2 → lager_cli-0.47.0}/box_storage.py +11 -4
  4. lager_cli-0.47.0/commands/box/_net_preflight.py +440 -0
  5. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_shim_verbs.py +12 -0
  6. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/boxes.py +2 -2
  7. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/config.py +536 -46
  8. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/nets.py +57 -23
  9. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/i2c.py +7 -1
  10. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/spi.py +78 -7
  11. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/defaults.py +5 -2
  12. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/update.py +37 -3
  13. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/webcam.py +140 -18
  14. {lager_cli-0.46.2 → lager_cli-0.47.0}/context/core.py +1 -1
  15. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/setup_and_deploy_box.sh +14 -4
  16. {lager_cli-0.46.2 → lager_cli-0.47.0/lager_cli.egg-info}/PKG-INFO +1 -1
  17. {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/SOURCES.txt +2 -0
  18. {lager_cli-0.46.2 → lager_cli-0.47.0}/LICENSE +0 -0
  19. {lager_cli-0.46.2 → lager_cli-0.47.0}/MANIFEST.in +0 -0
  20. {lager_cli-0.46.2 → lager_cli-0.47.0}/README.md +0 -0
  21. {lager_cli-0.46.2 → lager_cli-0.47.0}/__main__.py +0 -0
  22. {lager_cli-0.46.2 → lager_cli-0.47.0}/address_utils.py +0 -0
  23. {lager_cli-0.46.2 → lager_cli-0.47.0}/battery/__init__.py +0 -0
  24. {lager_cli-0.46.2 → lager_cli-0.47.0}/battery/battery_tui.py +0 -0
  25. {lager_cli-0.46.2 → lager_cli-0.47.0}/battery/websocket_client.py +0 -0
  26. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/__init__.py +0 -0
  27. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/__init__.py +0 -0
  28. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_device_identity.py +0 -0
  29. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_host_ops.py +0 -0
  30. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_mount_prep.py +0 -0
  31. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_pip_validation.py +0 -0
  32. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/_ssh.py +0 -0
  33. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/box_group.py +0 -0
  34. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/diagnose.py +0 -0
  35. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/dut.py +0 -0
  36. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/hello.py +0 -0
  37. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/instruments.py +0 -0
  38. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/labjack_pins.py +0 -0
  39. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/lock.py +0 -0
  40. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/net_tui.py +0 -0
  41. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/ssh.py +0 -0
  42. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/box/ssh_setup.py +0 -0
  43. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/__init__.py +0 -0
  44. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/ble.py +0 -0
  45. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/blufi.py +0 -0
  46. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/router.py +0 -0
  47. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/uart.py +0 -0
  48. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/usb.py +0 -0
  49. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/websocket_client.py +0 -0
  50. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/communication/wifi.py +0 -0
  51. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/__init__.py +0 -0
  52. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/arm.py +0 -0
  53. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/__init__.py +0 -0
  54. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/commands.py +0 -0
  55. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/net_cache.py +0 -0
  56. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/rtt_websocket_client.py +0 -0
  57. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/service_client.py +0 -0
  58. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/service_helper.py +0 -0
  59. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/debug/tunnel.py +0 -0
  60. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/devenv.py +0 -0
  61. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/development/python.py +0 -0
  62. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/__init__.py +0 -0
  63. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/adc.py +0 -0
  64. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/dac.py +0 -0
  65. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/energy.py +0 -0
  66. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/gpi.py +0 -0
  67. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/gpo.py +0 -0
  68. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/logic.py +0 -0
  69. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/scope.py +0 -0
  70. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/thermocouple.py +0 -0
  71. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/measurement/watt.py +0 -0
  72. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/__init__.py +0 -0
  73. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/battery.py +0 -0
  74. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/battery_model_csv.py +0 -0
  75. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/eload.py +0 -0
  76. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/solar.py +0 -0
  77. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/power/supply.py +0 -0
  78. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/__init__.py +0 -0
  79. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/_host_cli.py +0 -0
  80. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/binaries.py +0 -0
  81. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/exec_.py +0 -0
  82. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/install.py +0 -0
  83. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/install_wheel.py +0 -0
  84. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/login.py +0 -0
  85. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/logs.py +0 -0
  86. {lager_cli-0.46.2 → lager_cli-0.47.0}/commands/utility/uninstall.py +0 -0
  87. {lager_cli-0.46.2 → lager_cli-0.47.0}/config.py +0 -0
  88. {lager_cli-0.46.2 → lager_cli-0.47.0}/context/__init__.py +0 -0
  89. {lager_cli-0.46.2 → lager_cli-0.47.0}/context/ci_detection.py +0 -0
  90. {lager_cli-0.46.2 → lager_cli-0.47.0}/context/constants.py +0 -0
  91. {lager_cli-0.46.2 → lager_cli-0.47.0}/context/error_handlers.py +0 -0
  92. {lager_cli-0.46.2 → lager_cli-0.47.0}/context/session.py +0 -0
  93. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/__init__.py +0 -0
  94. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/group_usage.py +0 -0
  95. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/matchers.py +0 -0
  96. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/net_group.py +0 -0
  97. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/net_helpers.py +0 -0
  98. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/net_storage.py +0 -0
  99. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/param_types.py +0 -0
  100. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/ssh_utils.py +0 -0
  101. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/utils.py +0 -0
  102. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/version_skew.py +0 -0
  103. {lager_cli-0.46.2 → lager_cli-0.47.0}/core/ws_diagnose.py +0 -0
  104. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/__init__.py +0 -0
  105. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/__init__.py +0 -0
  106. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/configure_docker_dns.py +0 -0
  107. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/configure_docker_dns.sh +0 -0
  108. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/convert_to_sparse_checkout.sh +0 -0
  109. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/scripts/setup_ssh_key.sh +0 -0
  110. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/security/__init__.py +0 -0
  111. {lager_cli-0.46.2 → lager_cli-0.47.0}/deployment/security/secure_box_firewall.sh +0 -0
  112. {lager_cli-0.46.2 → lager_cli-0.47.0}/errors.py +0 -0
  113. {lager_cli-0.46.2 → lager_cli-0.47.0}/exceptions.py +0 -0
  114. {lager_cli-0.46.2 → lager_cli-0.47.0}/gateway_auth.py +0 -0
  115. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/__init__.py +0 -0
  116. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/box_config.py +0 -0
  117. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/measurement/__init__.py +0 -0
  118. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/measurement/scope.py +0 -0
  119. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/measurement/scope_stream.py +0 -0
  120. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/power/__init__.py +0 -0
  121. {lager_cli-0.46.2 → lager_cli-0.47.0}/impl/power/enable_disable.py +0 -0
  122. {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/dependency_links.txt +0 -0
  123. {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/entry_points.txt +0 -0
  124. {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/requires.txt +0 -0
  125. {lager_cli-0.46.2 → lager_cli-0.47.0}/lager_cli.egg-info/top_level.txt +0 -0
  126. {lager_cli-0.46.2 → lager_cli-0.47.0}/main.py +0 -0
  127. {lager_cli-0.46.2 → lager_cli-0.47.0}/pyproject.toml +0 -0
  128. {lager_cli-0.46.2 → lager_cli-0.47.0}/safe_unpickle.py +0 -0
  129. {lager_cli-0.46.2 → lager_cli-0.47.0}/setup.cfg +0 -0
  130. {lager_cli-0.46.2 → lager_cli-0.47.0}/setup.py +0 -0
  131. {lager_cli-0.46.2 → lager_cli-0.47.0}/simple_hdlc.py +0 -0
  132. {lager_cli-0.46.2 → lager_cli-0.47.0}/sort_utils.py +0 -0
  133. {lager_cli-0.46.2 → lager_cli-0.47.0}/status.py +0 -0
  134. {lager_cli-0.46.2 → lager_cli-0.47.0}/supply/__init__.py +0 -0
  135. {lager_cli-0.46.2 → lager_cli-0.47.0}/supply/supply_tui.py +0 -0
  136. {lager_cli-0.46.2 → lager_cli-0.47.0}/supply/websocket_client.py +0 -0
  137. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/__init__.py +0 -0
  138. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/core/__init__.py +0 -0
  139. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/core/executor.py +0 -0
  140. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/main.py +0 -0
  141. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/__init__.py +0 -0
  142. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/completer.py +0 -0
  143. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/display.py +0 -0
  144. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/logo.py +0 -0
  145. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/repl.py +0 -0
  146. {lager_cli-0.46.2 → lager_cli-0.47.0}/terminal/ui/themes.py +0 -0
  147. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_box_lager_imports.py +0 -0
  148. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_box_storage.py +0 -0
  149. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_gateway_auth.py +0 -0
  150. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_gateway_callsites.py +0 -0
  151. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_host_cli.py +0 -0
  152. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_io_imports.py +0 -0
  153. {lager_cli-0.46.2 → lager_cli-0.47.0}/tests/test_update_gate.py +0 -0
  154. {lager_cli-0.46.2 → lager_cli-0.47.0}/update_check.py +0 -0
  155. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16.py +0 -0
  156. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16DNP.py +0 -0
  157. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16Kermit.py +0 -0
  158. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC16SICK.py +0 -0
  159. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRC32.py +0 -0
  160. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/CRCCCITT.py +0 -0
  161. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/PyCRC/__init__.py +0 -0
  162. {lager_cli-0.46.2 → lager_cli-0.47.0}/vendor/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lager-cli
3
- Version: 0.46.2
3
+ Version: 0.47.0
4
4
  Summary: Lager CLI - Box and Docker connectivity
5
5
  Home-page: https://github.com/lagerdata/lager
6
6
  Author: Lager Data
@@ -7,4 +7,4 @@ Lager CLI
7
7
  A Command Line Interface for Lager Data
8
8
  """
9
9
 
10
- __version__ = '0.46.2'
10
+ __version__ = '0.47.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>``; show just the email. The ``ci:``
403
- # prefix is excluded above, and requiring an ``@`` keeps genuinely
404
- # unrecognized strings visible unchanged.
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]