File size: 7,863 Bytes
ad2cbeb
 
981ffc0
ad2cbeb
8f654f0
ad2cbeb
 
 
 
 
 
 
 
 
 
8f654f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad2cbeb
 
981ffc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad2cbeb
 
 
 
 
 
 
 
 
 
 
 
8f654f0
 
 
 
 
 
981ffc0
ad2cbeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Prepare HA configuration and supervise the three Space services."""
import os
import json
from pathlib import Path
import yaml
import shutil
import signal
import subprocess
import time

CONFIG = Path(os.getenv("HOME_ASSISTANT_CONFIG", "/data/home-assistant"))
BACKUP = "/usr/local/bin/backup-homeassistant.py"


def prepare_http(text):
    # Inspect YAML nodes without constructing !include / !secret values.
    # Preserve all unrelated source text instead of rewriting the whole document.
    root = yaml.compose(text)
    if root is None:
        text = "default_config:\n"
        root = yaml.compose(text)
    if not isinstance(root, yaml.MappingNode):
        raise ValueError("configuration.yaml must contain a top-level mapping")
    matches = [(key, value) for key, value in root.value if key.value == "http"]
    if len(matches) > 1:
        raise ValueError("Duplicate top-level http sections; merge them before startup")
    settings = (
        "http:\n"
        "  server_port: 8123\n"
        "  use_x_forwarded_for: true\n"
        "  trusted_proxies:\n"
        "    - 127.0.0.1\n"
        "    - ::1\n"
        "    - 10.0.0.0/8\n"
    )
    if not matches:
        result = text.rstrip() + "\n\n" + settings
    else:
        key, value = matches[0]
        if isinstance(value, yaml.MappingNode):
            kept = []
            seen = set()
            for child_key, child_value in value.value:
                if child_key.value in seen:
                    raise ValueError("Duplicate key inside http configuration")
                seen.add(child_key.value)
                if child_key.value not in {"server_port", "use_x_forwarded_for", "trusted_proxies"}:
                    kept.append("  " + text[child_key.start_mark.index:child_value.end_mark.index].rstrip() + "\n")
            settings += "".join(kept)
        elif value.tag != "tag:yaml.org,2002:null":
            raise ValueError("http must be an inline mapping; move http: !include settings into configuration.yaml")
        # Replace through the next root key, independent of indentation or CRLF.
        index = next(i for i, pair in enumerate(root.value) if pair[0] is key)
        end = root.value[index + 1][0].start_mark.index if index + 1 < len(root.value) else len(text)
        result = text[:key.start_mark.index] + settings + text[end:]
    validate_http(result)
    return result


def validate_http(text):
    root = yaml.compose(text)
    http = next(value for key, value in root.value if key.value == "http")
    values = {key.value: value for key, value in http.value}
    if values["server_port"].value != "8123" or values["use_x_forwarded_for"].tag != "tag:yaml.org,2002:bool" or values["use_x_forwarded_for"].value != "true":
        raise ValueError("HTTP proxy configuration validation failed")
    if "127.0.0.1" not in [item.value for item in values["trusted_proxies"].value]:
        raise ValueError("HTTP proxy configuration must trust local Nginx")


def prepare_http_storage(config_dir):
    """Sync migrated HA HTTP storage before HA starts; preserve all other settings."""
    path = config_dir / ".storage" / "http"
    if not path.exists():
        print("No stored HTTP configuration; HA will import YAML settings.", flush=True)
        return
    document = json.loads(path.read_text(encoding="utf-8"))
    version = document.get("version")
    data = document.get("data")
    if not isinstance(data, dict):
        raise ValueError("Invalid .storage/http payload; refusing to start")
    settings = {
        "server_port": 8123,
        "use_x_forwarded_for": True,
        "trusted_proxies": ["127.0.0.1/32", "::1/128", "10.0.0.0/8"],
    }
    if version == 1:
        data.update(settings)
    elif version == 2:
        if not isinstance(data.get("stable"), dict):
            raise ValueError("Invalid stable HTTP storage configuration")
        data["stable"].update(settings)
        data["stable"]["error"] = None
        data["stable"]["error_message"] = None
        # Remove an unconfirmed trial so it cannot override the stable config.
        data["pending"] = None
    else:
        raise ValueError(f"Unsupported HTTP storage version {version}; review before migrating")
    original = path.with_name("http.before-space-proxy.json")
    if not original.exists():
        shutil.copy2(path, original)
    pending = path.with_name("http.space.tmp")
    pending.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    pending.replace(path)
    print(f"HA HTTP storage v{version} verified: port=8123, use_x_forwarded_for=true, trusted_proxy=127.0.0.1", flush=True)


def backup(action):
    result = subprocess.run(["python3", BACKUP, action, "--config", str(CONFIG)], timeout=120)
    if result.returncode:
        print(f"Backup operation {action} failed; check Hub secrets and permissions.", flush=True)


def main():
    CONFIG.mkdir(parents=True, exist_ok=True)
    config_file = CONFIG / "configuration.yaml"
    if not config_file.exists():
        backup("restore")
    text = config_file.read_text() if config_file.exists() else "default_config:\n"
    updated = prepare_http(text)
    pending = config_file.with_suffix(".yaml.tmp")
    pending.write_text(updated, encoding="utf-8")
    pending.replace(config_file)
    validate_http(config_file.read_text(encoding="utf-8"))
    print("HA HTTP configuration verified: port=8123, use_x_forwarded_for=true, trusted_proxy=127.0.0.1", flush=True)
    prepare_http_storage(CONFIG)
    components = CONFIG / "custom_components"
    components.mkdir(exist_ok=True)
    for source in Path("/opt/ha-custom-components").iterdir():
        if not (components / source.name).exists():
            shutil.copytree(source, components / source.name)

    stopping = False
    def stop(signum, frame):
        nonlocal stopping
        stopping = True
    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)
    processes = []
    exit_code = 0
    try:
        for command in (
            ["hass", "--config", str(CONFIG)],
            ["/opt/mcp-venv/bin/python", "-m", "uvicorn", "server:app", "--app-dir", "/opt/mcp-server", "--host", "127.0.0.1", "--port", "8000", "--no-access-log"],
            ["nginx", "-g", "daemon off;"],
        ):
            processes.append(subprocess.Popen(command, start_new_session=True))
        next_backup = time.monotonic() + 60
        enabled = bool(os.getenv("HF_TOKEN") and os.getenv("BACKUP_REPO_ID"))
        while not stopping:
            for process in processes:
                if process.poll() is not None:
                    print(f"Service exited with code {process.returncode}; stopping container.", flush=True)
                    exit_code = process.returncode or 1
                    stopping = True
                    break
            if enabled and not stopping and time.monotonic() >= next_backup:
                try:
                    backup("backup")
                except Exception as exc:
                    print(f"Periodic backup failed: {type(exc).__name__}", flush=True)
                next_backup = time.monotonic() + 600
            time.sleep(1)
    finally:
        for process in reversed(processes):
            if process.poll() is None:
                os.killpg(process.pid, signal.SIGTERM)
        for process in processes:
            try:
                process.wait(timeout=25)
            except subprocess.TimeoutExpired:
                os.killpg(process.pid, signal.SIGKILL)
                process.wait()
        if os.getenv("HF_TOKEN") and os.getenv("BACKUP_REPO_ID"):
            try:
                backup("backup")
            except Exception as exc:
                print(f"Shutdown backup failed: {type(exc).__name__}", flush=True)
    return exit_code

if __name__ == "__main__":
    raise SystemExit(main())