boneIO 1.6.0.dev3__py3-none-any.whl → 1.6.0.dev5__py3-none-any.whl
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.
- boneio/core/cloud/__init__.py +2 -2
- boneio/core/cloud/data/docker-compose-cloud.yaml +4 -0
- boneio/core/cloud/data/docker-compose.yaml +4 -0
- boneio/core/cloud/data/init-certs-cloud.sh +6 -28
- boneio/core/cloud/registration.py +78 -3
- boneio/core/config/provenance.py +114 -0
- boneio/core/config/yaml_patch.py +121 -0
- boneio/core/containers.py +6 -1
- boneio/core/security/certificate.py +297 -0
- boneio/core/security/posture.py +89 -0
- boneio/migrations/assets/MANIFEST.sha256 +3 -2
- boneio/migrations/assets/docker/nodered/caddy/init-certs.sh +31 -32
- boneio/migrations/assets/helpers/boneio-containers +4 -1
- boneio/migrations/assets/issue.d/10-boneio.issue +1 -0
- boneio/migrations/plans/1.3.0.json +1 -1
- boneio/migrations/plans/1.3.0.sig +0 -0
- boneio/migrations/plans/1.4.0.json +1 -1
- boneio/migrations/plans/1.4.0.sig +2 -1
- boneio/migrations/plans/1.4.4.json +1 -1
- boneio/migrations/plans/1.4.4.sig +1 -1
- boneio/migrations/plans/1.6.12.json +1 -0
- boneio/migrations/plans/1.6.12.sig +1 -0
- boneio/migrations/plans/1.6.13.json +1 -0
- boneio/migrations/plans/1.6.13.sig +1 -0
- boneio/migrations/plans/1.6.4.json +1 -1
- boneio/migrations/plans/1.6.4.sig +0 -0
- boneio/migrations/plans/1.6.5.json +1 -1
- boneio/migrations/plans/1.6.5.sig +2 -2
- boneio/migrations/plans/manifest.json +1 -1
- boneio/migrations/plans/manifest.sig +1 -1
- boneio/migrations/versions/v1_3_0_baseline.py +3 -0
- boneio/migrations/versions/v1_4_0_fix_mqtt_sudoers.py +3 -0
- boneio/migrations/versions/v1_6_12_console_shows_ip.py +58 -0
- boneio/migrations/versions/v1_6_13_custom_certificate.py +52 -0
- boneio/migrations/versions/v1_6_4_ssh_login_hardening.py +3 -0
- boneio/runner.py +7 -0
- boneio/schema/schema.yaml +11 -0
- boneio/version.py +1 -1
- boneio/webui/app.py +23 -8
- boneio/webui/bind.py +193 -0
- boneio/webui/frontend-dist/assets/{AccountsView-iveRbldx.js → AccountsView-Bmey_Yr2.js} +1 -1
- boneio/webui/frontend-dist/assets/{ActionFields-DdCxR8lP.js → ActionFields-Dd53m3KH.js} +1 -1
- boneio/webui/frontend-dist/assets/{BackupSection-C1qj4qOG.js → BackupSection-BIhcVjYm.js} +1 -1
- boneio/webui/frontend-dist/assets/{BindingMatrix-Bj6KE0yY.js → BindingMatrix-D9Pr22Wp.js} +1 -1
- boneio/webui/frontend-dist/assets/{DeviceControlSection-CPmqccka.js → DeviceControlSection-DKC8tOXc.js} +1 -1
- boneio/webui/frontend-dist/assets/DiagnosticsView-C_oo-SRw.js +5 -0
- boneio/webui/frontend-dist/assets/{EditItemDialog-BH1onHq0.js → EditItemDialog-Dm4kPdmF.js} +1 -1
- boneio/webui/frontend-dist/assets/{EntityCard-yD9_Hl71.js → EntityCard-CkJgrIUC.js} +1 -1
- boneio/webui/frontend-dist/assets/{FactoryResetSection-E3xtkFLr.js → FactoryResetSection-sz3F--K9.js} +1 -1
- boneio/webui/frontend-dist/assets/{FormInputToggle-Spiz-w7l.js → FormInputToggle-C7Nq2Hp8.js} +1 -1
- boneio/webui/frontend-dist/assets/{HaDashboardWizard-f8fMg8FC.js → HaDashboardWizard-DEf4Sy_8.js} +1 -1
- boneio/webui/frontend-dist/assets/{HostnameSection-DrpKbp6I.js → HostnameSection-D0zvj1uy.js} +1 -1
- boneio/webui/frontend-dist/assets/{InputsView-W99g8hSl.js → InputsView-DkpdOPNR.js} +1 -1
- boneio/webui/frontend-dist/assets/{LocationSection-ZILUsfeR.js → LocationSection-D5EoVv-i.js} +2 -2
- boneio/webui/frontend-dist/assets/{LoggerForm-Chw9zm0N.js → LoggerForm-DHJUPaaV.js} +1 -1
- boneio/webui/frontend-dist/assets/{MigrationsSection-DSbw5UVz.js → MigrationsSection-BPvuefg6.js} +1 -1
- boneio/webui/frontend-dist/assets/{ModbusView-CdP0fXYx.js → ModbusView-Cch7ykif.js} +1 -1
- boneio/webui/frontend-dist/assets/{MqttPasswordsSection-BYwPAyFi.js → MqttPasswordsSection-dncPiufr.js} +1 -1
- boneio/webui/frontend-dist/assets/{NodeRedManagement-DRMiFlH2.js → NodeRedManagement-Cowu2_4i.js} +1 -1
- boneio/webui/frontend-dist/assets/{OutputsView-C9sDLF1P.js → OutputsView-C_PNR9px.js} +1 -1
- boneio/webui/frontend-dist/assets/{ScheduleSection-C2qLKNWR.js → ScheduleSection-ByEXYj3H.js} +1 -1
- boneio/webui/frontend-dist/assets/SecurityView-BGcKCB34.js +1 -0
- boneio/webui/frontend-dist/assets/{SensorView-DNtKJE4b.js → SensorView-DxwEx546.js} +1 -1
- boneio/webui/frontend-dist/assets/{SystemState-DbY5T6BB.js → SystemState-CvlqTKsV.js} +1 -1
- boneio/webui/frontend-dist/assets/{TemplatesView-6kNzVE3p.js → TemplatesView-D0PTisZV.js} +1 -1
- boneio/webui/frontend-dist/assets/{TilePicker-jd3hKaoZ.js → TilePicker-I46eKfzF.js} +1 -1
- boneio/webui/frontend-dist/assets/{TimezoneSection-BEuXBmWp.js → TimezoneSection-DRA9cBch.js} +1 -1
- boneio/webui/frontend-dist/assets/{UISettings-DIcCTtXU.js → UISettings-qOAoRlwa.js} +3 -3
- boneio/webui/frontend-dist/assets/{index-1pLHiRke.js → index-BDR1Gl2A.js} +24 -4
- boneio/webui/frontend-dist/assets/{monaco-C9XC2D1j.js → monaco-CQ8fwGzT.js} +1 -1
- boneio/webui/frontend-dist/index.html +1 -1
- boneio/webui/frontend-dist/schema/config.schema.json +13 -0
- boneio/webui/frontend-dist/schema/web.schema.json +13 -0
- boneio/webui/frontend-dist/sw.js +1 -1
- boneio/webui/routes/config_core.py +131 -1
- boneio/webui/routes/onboarding.py +9 -0
- boneio/webui/routes/security.py +297 -2
- boneio/webui/routes/system.py +8 -0
- boneio/webui/schema/config.schema.json +13 -0
- boneio/webui/schema/web.schema.json +13 -0
- boneio/webui/web_server.py +5 -1
- boneio/webui/websocket_manager.py +42 -2
- {boneio-1.6.0.dev3.dist-info → boneio-1.6.0.dev5.dist-info}/METADATA +3 -3
- {boneio-1.6.0.dev3.dist-info → boneio-1.6.0.dev5.dist-info}/RECORD +88 -78
- boneio/webui/frontend-dist/assets/DiagnosticsView-Bi1hrIcs.js +0 -5
- boneio/webui/frontend-dist/assets/SecurityView-DBkwBU2t.js +0 -1
- {boneio-1.6.0.dev3.dist-info → boneio-1.6.0.dev5.dist-info}/WHEEL +0 -0
- {boneio-1.6.0.dev3.dist-info → boneio-1.6.0.dev5.dist-info}/entry_points.txt +0 -0
- {boneio-1.6.0.dev3.dist-info → boneio-1.6.0.dev5.dist-info}/licenses/LICENSE +0 -0
- {boneio-1.6.0.dev3.dist-info → boneio-1.6.0.dev5.dist-info}/top_level.txt +0 -0
boneio/core/cloud/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""Cloud registration module for boneIO Black PWA support."""
|
|
2
2
|
|
|
3
|
-
from boneio.core.cloud.registration import CloudRegistration
|
|
3
|
+
from boneio.core.cloud.registration import CloudRegistration, set_enabled
|
|
4
4
|
|
|
5
|
-
__all__ = ["CloudRegistration"]
|
|
5
|
+
__all__ = ["CloudRegistration", "set_enabled"]
|
|
@@ -20,6 +20,10 @@ services:
|
|
|
20
20
|
- "${HTTPS_PORT:-8443}:443"
|
|
21
21
|
environment:
|
|
22
22
|
- HOST_HOSTNAME=${HOSTNAME}
|
|
23
|
+
# The port the host publishes 443 on. Caddy only sees 443 from
|
|
24
|
+
# inside the container, so a redirect built from what it sees
|
|
25
|
+
# would send browsers to a port nothing listens on.
|
|
26
|
+
- PUBLIC_HTTPS_PORT=${HTTPS_PORT:-8443}
|
|
23
27
|
volumes:
|
|
24
28
|
- ./caddy/502.html:/srv/502.html:ro
|
|
25
29
|
- ./caddy/init-certs-cloud.sh:/init-certs-cloud.sh:ro
|
|
@@ -25,6 +25,10 @@ services:
|
|
|
25
25
|
- "${HTTPS_PORT:-8443}:443"
|
|
26
26
|
environment:
|
|
27
27
|
- HOST_HOSTNAME=${HOSTNAME:-boneio}
|
|
28
|
+
# The port the host publishes 443 on. Caddy only sees 443 from
|
|
29
|
+
# inside the container, so a redirect built from what it sees
|
|
30
|
+
# would send browsers to a port nothing listens on.
|
|
31
|
+
- PUBLIC_HTTPS_PORT=${HTTPS_PORT:-8443}
|
|
28
32
|
volumes:
|
|
29
33
|
- ./caddy/502.html:/srv/502.html:ro
|
|
30
34
|
- ./caddy/init-certs.sh:/init-certs.sh:ro
|
|
@@ -79,34 +79,12 @@ cat > /tmp/Caddyfile << EOF
|
|
|
79
79
|
|
|
80
80
|
# HTTP - serve directly
|
|
81
81
|
:80 {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
handle /nodered-status {
|
|
92
|
-
header Content-Type application/json
|
|
93
|
-
header X-NodeRed-Available "true"
|
|
94
|
-
header Access-Control-Expose-Headers "X-NodeRed-Available"
|
|
95
|
-
respond \`{"available": true}\` 200
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
handle /nodered/* {
|
|
99
|
-
reverse_proxy node-red:1880 {
|
|
100
|
-
header_up X-Forwarded-Proto {scheme}
|
|
101
|
-
header_down X-NodeRed-Available "true"
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
handle {
|
|
106
|
-
reverse_proxy host.docker.internal:8090 {
|
|
107
|
-
header_up X-Forwarded-Proto {scheme}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
82
|
+
# Redirect to HTTPS. This port used to serve the panel in the clear —
|
|
83
|
+
# the login form, the token it hands back, and a configuration carrying
|
|
84
|
+
# passwords. The port is substituted by this shell as the file is
|
|
85
|
+
# written: Caddy only sees 443 from inside the container, while the
|
|
86
|
+
# host publishes it as 8443.
|
|
87
|
+
redir https://{host}:${PUBLIC_HTTPS_PORT:-8443}{uri}
|
|
110
88
|
}
|
|
111
89
|
|
|
112
90
|
${CLOUD_BLOCK}
|
|
@@ -41,9 +41,11 @@ KEY_FILE = CERT_DIR / "privkey.pem"
|
|
|
41
41
|
|
|
42
42
|
# Caddy configuration paths
|
|
43
43
|
CADDY_CONFIG_DIR = _DOCKER_DIR / "caddy"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
# No Caddyfile constants here on purpose. Caddy's configuration is not a file
|
|
45
|
+
# in this directory — init-certs.sh (or init-certs-cloud.sh) writes it to
|
|
46
|
+
# /tmp/Caddyfile inside the container on every start. Switching between local
|
|
47
|
+
# and cloud mode swaps the compose file, and with it which of those two scripts
|
|
48
|
+
# runs; there is no Caddyfile to copy over another.
|
|
47
49
|
|
|
48
50
|
# Registration interval (1 hour)
|
|
49
51
|
REGISTRATION_INTERVAL = 3600
|
|
@@ -637,3 +639,76 @@ class CloudRegistration:
|
|
|
637
639
|
return "init-certs-cloud.sh" in content
|
|
638
640
|
except Exception:
|
|
639
641
|
return False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async def set_enabled(config_helper, enabled: bool, local_ip: str | None = None) -> str:
|
|
645
|
+
"""Turn cloud registration on or off on a running device.
|
|
646
|
+
|
|
647
|
+
Registration used to begin only in :mod:`boneio.runner`, so switching it on
|
|
648
|
+
from the panel wrote a line to config.yaml and asked for a restart —
|
|
649
|
+
on a controller whose whole point is that it is running. Everything the
|
|
650
|
+
change needs is already here: :meth:`CloudRegistration.start` does no more
|
|
651
|
+
than launch the loop that registers the name, fetches the certificate,
|
|
652
|
+
swaps the compose template and recreates Caddy.
|
|
653
|
+
|
|
654
|
+
Idempotent in both directions, because the panel can be open twice and a
|
|
655
|
+
save can be repeated.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
config_helper: The live :class:`ConfigHelper`.
|
|
659
|
+
enabled: The state asked for.
|
|
660
|
+
local_ip: This device's address on the LAN. Read from the system when
|
|
661
|
+
not given.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
A short word for what happened: ``started``, ``stopped``, ``unchanged``
|
|
665
|
+
or ``unavailable``.
|
|
666
|
+
"""
|
|
667
|
+
existing = getattr(config_helper, "_cloud_reg", None)
|
|
668
|
+
|
|
669
|
+
if not enabled:
|
|
670
|
+
config_helper._cloud_registration = False
|
|
671
|
+
if existing is None:
|
|
672
|
+
return "unchanged"
|
|
673
|
+
try:
|
|
674
|
+
await existing.stop()
|
|
675
|
+
# Put the plain template back, or Caddy keeps trying to serve a
|
|
676
|
+
# certificate for a name that is no longer being renewed.
|
|
677
|
+
await existing._restore_local_config()
|
|
678
|
+
except Exception as err: # noqa: BLE001
|
|
679
|
+
_LOGGER.warning("Could not stop cloud registration cleanly: %s", err)
|
|
680
|
+
config_helper._cloud_reg = None
|
|
681
|
+
_LOGGER.info("Cloud registration stopped on request")
|
|
682
|
+
return "stopped"
|
|
683
|
+
|
|
684
|
+
if existing is not None:
|
|
685
|
+
config_helper._cloud_registration = True
|
|
686
|
+
return "unchanged"
|
|
687
|
+
|
|
688
|
+
serial = getattr(config_helper, "serial_number", None)
|
|
689
|
+
if not local_ip:
|
|
690
|
+
try:
|
|
691
|
+
from boneio.core.system.monitor import get_network_info
|
|
692
|
+
|
|
693
|
+
local_ip = (get_network_info() or {}).get("ip", "")
|
|
694
|
+
except Exception as err: # noqa: BLE001
|
|
695
|
+
_LOGGER.warning("Could not read this device's address: %s", err)
|
|
696
|
+
local_ip = ""
|
|
697
|
+
|
|
698
|
+
if not serial or not local_ip:
|
|
699
|
+
# The setting is saved either way; the next start picks it up. Saying
|
|
700
|
+
# so is better than reporting success for a service that did not begin.
|
|
701
|
+
_LOGGER.warning(
|
|
702
|
+
"Cloud registration is enabled but %s is not known yet; it will "
|
|
703
|
+
"start on the next boot.",
|
|
704
|
+
"the serial number" if not serial else "this device's address",
|
|
705
|
+
)
|
|
706
|
+
config_helper._cloud_registration = True
|
|
707
|
+
return "unavailable"
|
|
708
|
+
|
|
709
|
+
registration = CloudRegistration(serial_number=serial, local_ip=local_ip)
|
|
710
|
+
await registration.start()
|
|
711
|
+
config_helper._cloud_reg = registration
|
|
712
|
+
config_helper._cloud_registration = True
|
|
713
|
+
_LOGGER.info("Cloud registration started for %s (IP: %s)", serial, local_ip)
|
|
714
|
+
return "started"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tell a freshly flashed controller from one that has been in service.
|
|
2
|
+
|
|
3
|
+
The first-run wizard has two steps that are only appropriate on a blank
|
|
4
|
+
device. The import step offers to restore a configuration, which on a device
|
|
5
|
+
that already has one reads as "yours is gone". The devices step is worse than
|
|
6
|
+
misleading: it replaces the whole ``event`` section, so a controller with fifty
|
|
7
|
+
configured inputs loses them to a wizard its owner opened only to create an
|
|
8
|
+
account.
|
|
9
|
+
|
|
10
|
+
So the question has to be answered before those steps are offered, and the two
|
|
11
|
+
ways of getting it wrong are not equally bad:
|
|
12
|
+
|
|
13
|
+
* Treating an upgraded device as fresh costs the owner their configuration.
|
|
14
|
+
* Treating a fresh device as upgraded costs them a step in a wizard; they
|
|
15
|
+
can still import and bind inputs from the normal interface afterwards.
|
|
16
|
+
|
|
17
|
+
This module is therefore written to answer "is it fresh" and to say no when it
|
|
18
|
+
cannot tell. Freshness has to be demonstrated, not assumed.
|
|
19
|
+
|
|
20
|
+
The demonstration is that the configuration is still the factory one. The image
|
|
21
|
+
copies a variant out of ``~/.cache/boneio_configs/<variant>/`` verbatim, and
|
|
22
|
+
per-device work touches only ``mqtt.yaml`` (the broker password), so a
|
|
23
|
+
controller nobody has configured yet still has a ``config.yaml`` byte-identical
|
|
24
|
+
to its template.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
_LOGGER = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
#: Where the image leaves the per-variant templates it seeded the device from.
|
|
36
|
+
FACTORY_TEMPLATE_DIR = Path.home() / ".cache" / "boneio_configs"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _digest(path: Path) -> bytes | None:
|
|
40
|
+
"""Read a file for comparison.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path: File to read.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Its contents, or None when it cannot be read.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
return path.read_bytes()
|
|
50
|
+
except OSError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def looks_factory_fresh(
|
|
55
|
+
config_path: str | os.PathLike[str],
|
|
56
|
+
template_dir: str | os.PathLike[str] | None = None,
|
|
57
|
+
) -> bool:
|
|
58
|
+
"""Whether this device still carries an untouched factory configuration.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config_path: The device's ``config.yaml``.
|
|
62
|
+
template_dir: Where the per-variant templates live. Defaults to
|
|
63
|
+
:data:`FACTORY_TEMPLATE_DIR`.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True only when freshness can be positively shown: there is no
|
|
67
|
+
configuration at all, or the configuration is identical to one of the
|
|
68
|
+
templates the image ships. False whenever the answer is not certain.
|
|
69
|
+
"""
|
|
70
|
+
config = Path(config_path)
|
|
71
|
+
current = _digest(config)
|
|
72
|
+
if current is None:
|
|
73
|
+
# Nothing to lose, and nothing that an import could overwrite.
|
|
74
|
+
_LOGGER.debug("No configuration at %s — treating the device as fresh", config)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
root = Path(template_dir) if template_dir is not None else FACTORY_TEMPLATE_DIR
|
|
78
|
+
try:
|
|
79
|
+
variants = sorted(p for p in root.iterdir() if p.is_dir())
|
|
80
|
+
except OSError:
|
|
81
|
+
# No templates to compare against: the honest answer is "cannot tell",
|
|
82
|
+
# and the safe reading of that is "assume it has been configured".
|
|
83
|
+
_LOGGER.debug("No factory templates under %s — assuming configured", root)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
for variant in variants:
|
|
87
|
+
if current == _digest(variant / "config.yaml"):
|
|
88
|
+
_LOGGER.debug("Configuration matches the %s template", variant.name)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def was_configured_before(
|
|
95
|
+
config_path: str | os.PathLike[str],
|
|
96
|
+
had_legacy_auth: bool = False,
|
|
97
|
+
template_dir: str | os.PathLike[str] | None = None,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""Whether the wizard should skip the steps that assume a blank device.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
config_path: The device's ``config.yaml``.
|
|
103
|
+
had_legacy_auth: Whether a pre-1.6 ``web.auth`` block was found. It
|
|
104
|
+
proves the device is not fresh, but its absence proves nothing —
|
|
105
|
+
most 1.5.x controllers never had web authentication at all, which
|
|
106
|
+
is exactly the case this used to get wrong.
|
|
107
|
+
template_dir: Passed through to :func:`looks_factory_fresh`.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True when the device already has a configuration of its own.
|
|
111
|
+
"""
|
|
112
|
+
if had_legacy_auth:
|
|
113
|
+
return True
|
|
114
|
+
return not looks_factory_fresh(config_path, template_dir)
|
boneio/core/config/yaml_patch.py
CHANGED
|
@@ -260,3 +260,124 @@ def ensure_section(config_file: str | Path, path: tuple[str, ...]) -> bool:
|
|
|
260
260
|
file_path.write_text("".join(lines), encoding="utf-8")
|
|
261
261
|
_LOGGER.info("Created section '%s' in %s", ".".join(path), file_path)
|
|
262
262
|
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _find_section_line(lines: list[str], path: tuple[str, ...]) -> int | None:
|
|
266
|
+
"""Index of the line declaring a nested key, or None if it is not there.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
lines: File lines.
|
|
270
|
+
path: Section path, outermost first.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The index of the key's own line, or None.
|
|
274
|
+
"""
|
|
275
|
+
depth = 0
|
|
276
|
+
for index, line in enumerate(lines):
|
|
277
|
+
parsed = _line_key(line)
|
|
278
|
+
if parsed is None:
|
|
279
|
+
continue
|
|
280
|
+
key, indent, _ = parsed
|
|
281
|
+
if depth and indent <= (depth - 1) * len(INDENT):
|
|
282
|
+
return None
|
|
283
|
+
if key == path[depth] and indent == depth * len(INDENT):
|
|
284
|
+
depth += 1
|
|
285
|
+
if depth == len(path):
|
|
286
|
+
return index
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def has_section(config_file: str | Path, path: tuple[str, ...]) -> bool:
|
|
291
|
+
"""Whether a nested key is present in the file.
|
|
292
|
+
|
|
293
|
+
Separate from :func:`remove_section` so a caller can find out before doing
|
|
294
|
+
anything irreversible — such as writing a backup copy over the one it made
|
|
295
|
+
the last time it was asked.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
config_file: Path to config.yaml.
|
|
299
|
+
path: Section path, outermost first.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
True when the key is there.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
YamlPatchError: If the path is empty.
|
|
306
|
+
"""
|
|
307
|
+
if not path:
|
|
308
|
+
raise YamlPatchError("No section path given.")
|
|
309
|
+
lines = Path(config_file).read_text(encoding="utf-8").splitlines(keepends=True)
|
|
310
|
+
return _find_section_line(lines, path) is not None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def remove_section(config_file: str | Path, path: tuple[str, ...]) -> bool:
|
|
314
|
+
"""Delete a nested mapping key and everything under it.
|
|
315
|
+
|
|
316
|
+
Written for one job: taking the pre-1.6 ``web.auth`` block out of a
|
|
317
|
+
configuration after its account has been migrated into the hashed store.
|
|
318
|
+
Nothing reads it any more, and it still holds the owner's password in
|
|
319
|
+
plain text — in the file, and in every backup and diagnostic bundle made
|
|
320
|
+
since.
|
|
321
|
+
|
|
322
|
+
Line-based rather than a parse-and-dump, for the usual reason: the file
|
|
323
|
+
belongs to the owner, and round-tripping it through a YAML library would
|
|
324
|
+
return their comments, ordering and ``!include`` directives as something
|
|
325
|
+
they did not write.
|
|
326
|
+
|
|
327
|
+
Comment lines immediately above the key are removed with it. They describe
|
|
328
|
+
the block, and leaving them behind stranded over an unrelated setting is
|
|
329
|
+
worse than taking one comment too many.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
config_file: Path to config.yaml.
|
|
333
|
+
path: Section path, outermost first, e.g. ``("web", "auth")``.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
True if something was removed, False if the key was not there.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
YamlPatchError: If the path is empty.
|
|
340
|
+
"""
|
|
341
|
+
if not path:
|
|
342
|
+
raise YamlPatchError("No section path given.")
|
|
343
|
+
|
|
344
|
+
file_path = Path(config_file)
|
|
345
|
+
lines = file_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
346
|
+
|
|
347
|
+
start = _find_section_line(lines, path)
|
|
348
|
+
if start is None:
|
|
349
|
+
return False
|
|
350
|
+
key_indent = len(lines[start]) - len(lines[start].lstrip())
|
|
351
|
+
|
|
352
|
+
# Everything indented under the key belongs to it, blank lines included
|
|
353
|
+
# while more indented content follows.
|
|
354
|
+
end = start + 1
|
|
355
|
+
while end < len(lines):
|
|
356
|
+
line = lines[end]
|
|
357
|
+
if not line.strip():
|
|
358
|
+
following = next(
|
|
359
|
+
(i for i in range(end + 1, len(lines)) if lines[i].strip()), None
|
|
360
|
+
)
|
|
361
|
+
if following is None:
|
|
362
|
+
break
|
|
363
|
+
next_indent = len(lines[following]) - len(lines[following].lstrip())
|
|
364
|
+
if next_indent <= key_indent:
|
|
365
|
+
break
|
|
366
|
+
end += 1
|
|
367
|
+
continue
|
|
368
|
+
indent = len(line) - len(line.lstrip())
|
|
369
|
+
if indent <= key_indent:
|
|
370
|
+
break
|
|
371
|
+
end += 1
|
|
372
|
+
|
|
373
|
+
# Take the comment block sitting directly on top of it.
|
|
374
|
+
while start > 0:
|
|
375
|
+
above = lines[start - 1].strip()
|
|
376
|
+
if above.startswith("#"):
|
|
377
|
+
start -= 1
|
|
378
|
+
continue
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
del lines[start:end]
|
|
382
|
+
file_path.write_text("".join(lines), encoding="utf-8")
|
|
383
|
+
return True
|
boneio/core/containers.py
CHANGED
|
@@ -55,7 +55,12 @@ _FALLBACK: dict[str, list[str]] = {
|
|
|
55
55
|
"restart-caddy": ["docker", "compose", "restart", CADDY_SERVICE],
|
|
56
56
|
"reload-caddy": [
|
|
57
57
|
"docker", "compose", "exec", CADDY_SERVICE,
|
|
58
|
-
|
|
58
|
+
# /tmp/Caddyfile, not /etc/caddy/Caddyfile: the container runs the
|
|
59
|
+
# file init-certs.sh writes on every start. /etc/caddy/Caddyfile is the
|
|
60
|
+
# stock config baked into the image, and reloading that swaps the
|
|
61
|
+
# working proxy for Caddy's welcome page — HTTPS goes down and stays
|
|
62
|
+
# down until the container is restarted.
|
|
63
|
+
"caddy", "reload", "--config", "/tmp/Caddyfile",
|
|
59
64
|
],
|
|
60
65
|
}
|
|
61
66
|
|