@topy-ai/maggie 0.7.53 → 0.7.55
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.
- package/README-zh-TW.md +28 -50
- package/README.md +80 -64
- package/bin/maggie.js +61 -9
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +15 -0
- package/bundled-skills/maggie-booking/SKILL.md +20 -12
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +37 -2
- package/bundled-skills/maggie-deployment/SKILL.md +104 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-design/SKILL.md +81 -0
- package/bundled-skills/maggie-feedback/SKILL.md +10 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
- package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
- package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +50 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_booking.py +9 -8
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
- package/bundled-tools/clis/maggie_design.py +7 -1
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +131 -1
- package/bundled-tools/clis/maggie_feedback.py +41 -13
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +147 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +84 -1
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +223 -2
- package/bundled-tools/clis/maggie_version_sources.py +93 -0
- package/bundled-tools/integrations/maggie-api-pull.md +49 -0
- package/package.json +1 -1
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -20,6 +20,10 @@ from urllib.request import Request, urlopen
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
|
|
23
|
+
# Both hosts accept the same anonymous contract and de-duplicate on feedbackId,
|
|
24
|
+
# so a draft is offered to the next one only when the previous host failed
|
|
25
|
+
# before storing it (network error or a 5xx such as Cloudflare's 52x).
|
|
26
|
+
DEFAULT_ENDPOINTS = (DEFAULT_ENDPOINT, "https://feedback.cmo.so/api/feedback")
|
|
23
27
|
SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
|
|
24
28
|
LOCAL_PATH_RE = re.compile(
|
|
25
29
|
r"(?<![A-Za-z0-9])(?:~/(?:[^\s,;:)\]}]+/)*[^\s,;:)\]}]+|"
|
|
@@ -348,28 +352,52 @@ def hosted_payload(data: dict) -> dict:
|
|
|
348
352
|
return payload
|
|
349
353
|
|
|
350
354
|
|
|
355
|
+
def submit_endpoints(value: object) -> list[str]:
|
|
356
|
+
endpoints = [value] if isinstance(value, str) else list(value or DEFAULT_ENDPOINTS)
|
|
357
|
+
result = []
|
|
358
|
+
for endpoint in endpoints:
|
|
359
|
+
endpoint = str(endpoint).rstrip("/")
|
|
360
|
+
parsed = urlparse(endpoint)
|
|
361
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
362
|
+
raise ValueError("endpoint must be an absolute HTTPS URL")
|
|
363
|
+
if endpoint not in result:
|
|
364
|
+
result.append(endpoint)
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
|
|
351
368
|
def submit(args: argparse.Namespace) -> int:
|
|
352
369
|
if not args.confirm:
|
|
353
370
|
raise ValueError("submit requires --confirm; feedback is never sent implicitly")
|
|
354
371
|
path = Path(args.feedback)
|
|
355
372
|
data = read_feedback(path)
|
|
356
|
-
|
|
357
|
-
parsed = urlparse(endpoint)
|
|
358
|
-
if parsed.scheme != "https" or not parsed.netloc:
|
|
359
|
-
raise ValueError("endpoint must be an absolute HTTPS URL")
|
|
373
|
+
endpoints = submit_endpoints(args.endpoint)
|
|
360
374
|
payload = hosted_payload(data)
|
|
361
375
|
payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
376
|
+
body = json.dumps(payload, ensure_ascii=False).encode()
|
|
377
|
+
failures = []
|
|
378
|
+
for endpoint in endpoints:
|
|
379
|
+
request = Request(endpoint, data=body, headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
|
|
380
|
+
try:
|
|
381
|
+
with urlopen(request, timeout=20) as response:
|
|
382
|
+
raw = response.read(1_000_000).decode("utf-8", "replace")
|
|
383
|
+
result = json.loads(raw) if raw else {}
|
|
384
|
+
break
|
|
385
|
+
except HTTPError as error:
|
|
386
|
+
failures.append({"endpoint": endpoint, "error": f"HTTP {error.code}"})
|
|
387
|
+
# A 4xx is the host rejecting this draft; another host would too.
|
|
388
|
+
if error.code < 500:
|
|
389
|
+
raise RuntimeError(f"feedback submission failed: {endpoint} answered HTTP {error.code}") from error
|
|
390
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
391
|
+
failures.append({"endpoint": endpoint, "error": safe_text(getattr(error, "reason", error))})
|
|
392
|
+
else:
|
|
393
|
+
tried = "; ".join(f"{item['endpoint']} ({item['error']})" for item in failures)
|
|
394
|
+
raise RuntimeError(f"feedback submission failed on every endpoint: {tried}")
|
|
369
395
|
ack = acknowledgement(result)
|
|
370
396
|
data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "acknowledgement": ack}
|
|
397
|
+
if failures:
|
|
398
|
+
data["submission"]["failedEndpoints"] = failures
|
|
371
399
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
372
|
-
print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "acknowledgement": ack}, indent=2, ensure_ascii=False))
|
|
400
|
+
print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "endpoint": endpoint, "failedEndpoints": failures, "acknowledgement": ack}, indent=2, ensure_ascii=False))
|
|
373
401
|
return 0
|
|
374
402
|
|
|
375
403
|
|
|
@@ -815,7 +843,7 @@ def main() -> int:
|
|
|
815
843
|
preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
816
844
|
submit_parser = sub.add_parser("submit")
|
|
817
845
|
submit_parser.add_argument("feedback")
|
|
818
|
-
submit_parser.add_argument("--endpoint", default
|
|
846
|
+
submit_parser.add_argument("--endpoint", action="append", help="HTTPS endpoint; repeat for an ordered fallback list (default: the NoBlox host, then feedback.cmo.so)")
|
|
819
847
|
submit_parser.add_argument("--confirm", action="store_true")
|
|
820
848
|
list_parser = sub.add_parser("list")
|
|
821
849
|
list_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Host runtime checks for VPS hosts that serve PHP or containers (``maggie deployment ...``).
|
|
3
|
+
|
|
4
|
+
- ``runtime-inventory``: read-only inventory of one host — OS release, every
|
|
5
|
+
active nginx server block (from ``nginx -T``, which includes files that are
|
|
6
|
+
symlinked into ``sites-enabled``; ``grep -r`` does not follow those links and
|
|
7
|
+
made a host look as if it had no PHP sites), its ``fastcgi_pass`` target,
|
|
8
|
+
host PHP versions and php-fpm units, containers and container units. With
|
|
9
|
+
``--advisory`` it marks each runtime against the fixed versions and writes a
|
|
10
|
+
reviewable migration plan per affected site. It also flags an operating
|
|
11
|
+
system past its end of standard security support and a pinned container
|
|
12
|
+
tag older than the newest patch of its line (``--latest-tags``). Input is a
|
|
13
|
+
collected evidence file (``--evidence``) or ``--ssh user@host``, which runs
|
|
14
|
+
only the read-only collector printed by ``--print-collector``.
|
|
15
|
+
- ``host-lock``: one agent session at a time on a production host. Acquire it
|
|
16
|
+
before editing systemd units, containers or nginx; ``status`` also lists
|
|
17
|
+
files changed recently under those directories (someone may be working
|
|
18
|
+
there without the lock). Two sessions once recreated the same container in
|
|
19
|
+
turn, each undoing the other's fix.
|
|
20
|
+
|
|
21
|
+
PHP images (``php:*-fpm``, ``wordpress:*-fpm``) are always planned with
|
|
22
|
+
``--entrypoint docker-php-entrypoint`` and an explicit ``php-fpm`` command:
|
|
23
|
+
the WordPress image's own entrypoint copies a fresh WordPress into a mounted
|
|
24
|
+
site folder that has no ``wp-config.php``, which once exposed a public
|
|
25
|
+
``wp-admin/setup-config.php`` on a site that was not WordPress.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shlex
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import date, datetime, timedelta, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
# End of standard security support (not paid extended support). Override or
|
|
41
|
+
# extend with --eol-table FILE ({"ubuntu": {"24.10": "2025-07-10"}}).
|
|
42
|
+
EOL = {
|
|
43
|
+
"ubuntu": {
|
|
44
|
+
"16.04": "2021-04-30", "18.04": "2023-05-31", "20.04": "2025-05-31", "22.04": "2027-06-01",
|
|
45
|
+
"24.04": "2029-05-31", "26.04": "2031-05-31",
|
|
46
|
+
"20.10": "2021-07-22", "21.04": "2022-01-20", "21.10": "2022-07-14", "22.10": "2023-07-20",
|
|
47
|
+
"23.04": "2024-01-25", "23.10": "2024-07-11", "24.10": "2025-07-10", "25.04": "2026-01-15",
|
|
48
|
+
"25.10": "2026-07-09",
|
|
49
|
+
},
|
|
50
|
+
"debian": {"9": "2022-06-30", "10": "2024-06-30", "11": "2026-08-31", "12": "2028-06-30", "13": "2030-06-30"},
|
|
51
|
+
}
|
|
52
|
+
SECTION = re.compile(r"^### (\S+)\s*$")
|
|
53
|
+
SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
|
54
|
+
PHP_IMAGE = re.compile(r"(^|/)(php|wordpress)(:|$)")
|
|
55
|
+
|
|
56
|
+
# Read-only collector. Each section starts with "### name"; the parser ignores
|
|
57
|
+
# anything it does not know. "nginx -T" prints every included file, so
|
|
58
|
+
# symlinked sites-enabled entries are covered; the grep fallback uses -R.
|
|
59
|
+
COLLECTOR = r"""set +e
|
|
60
|
+
s() { printf '\n### %s\n' "$1"; }
|
|
61
|
+
SUDO=""; [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null && SUDO="sudo -n"
|
|
62
|
+
s os-release; cat /etc/os-release 2>/dev/null
|
|
63
|
+
s nginx-T; $SUDO nginx -T 2>/dev/null
|
|
64
|
+
s nginx-fastcgi-files; $SUDO grep -RIl fastcgi_pass /etc/nginx 2>/dev/null
|
|
65
|
+
s php-v; php -v 2>/dev/null | head -1
|
|
66
|
+
s php-fpm-units; systemctl list-units --all --no-legend --plain 'php*-fpm*' 2>/dev/null
|
|
67
|
+
s docker-ps; $SUDO docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Mounts}}' 2>/dev/null
|
|
68
|
+
s docker-entrypoints; $SUDO docker ps -q 2>/dev/null | xargs -r $SUDO docker inspect --format '{{.Name}}\t{{.Config.Image}}\t{{json .Config.Entrypoint}}' 2>/dev/null
|
|
69
|
+
for f in $($SUDO grep -RlE '(docker|podman) run' /etc/systemd/system 2>/dev/null | sort -u); do s "unit:$f"; $SUDO cat "$f"; done
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_sections(text: str) -> dict[str, str]:
|
|
74
|
+
sections: dict[str, list[str]] = {}
|
|
75
|
+
current = None
|
|
76
|
+
for line in text.splitlines():
|
|
77
|
+
match = SECTION.match(line)
|
|
78
|
+
if match:
|
|
79
|
+
current = match.group(1)
|
|
80
|
+
sections.setdefault(current, [])
|
|
81
|
+
elif current:
|
|
82
|
+
sections[current].append(line)
|
|
83
|
+
return {name: "\n".join(lines).strip("\n") for name, lines in sections.items()}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_os(text: str) -> dict:
|
|
87
|
+
values = {}
|
|
88
|
+
for line in text.splitlines():
|
|
89
|
+
if "=" in line:
|
|
90
|
+
key, _, value = line.partition("=")
|
|
91
|
+
values[key.strip()] = value.strip().strip('"')
|
|
92
|
+
return {"id": values.get("ID", ""), "version": values.get("VERSION_ID", ""), "name": values.get("PRETTY_NAME", "")}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def os_finding(os_info: dict, today: date, table: dict) -> dict | None:
|
|
96
|
+
ends = table.get(os_info.get("id", ""), {}).get(os_info.get("version", ""))
|
|
97
|
+
if not ends:
|
|
98
|
+
return {"kind": "os-unknown", "severity": "warning", "message": f"no end-of-support date known for {os_info.get('name') or 'this OS'}; check it by hand or pass --eol-table"} if os_info.get("id") else None
|
|
99
|
+
end = date.fromisoformat(ends)
|
|
100
|
+
if end < today:
|
|
101
|
+
return {"kind": "os-eol", "severity": "error", "ends": ends, "message": f"{os_info['name']} stopped getting standard security fixes on {ends}; plan an upgrade to a supported release"}
|
|
102
|
+
if end - today < timedelta(days=120):
|
|
103
|
+
return {"kind": "os-eol-soon", "severity": "warning", "ends": ends, "message": f"{os_info['name']} stops getting standard security fixes on {ends}"}
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def strip_comment(line: str) -> str:
|
|
108
|
+
return line.split("#", 1)[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_nginx(text: str) -> list[dict]:
|
|
112
|
+
"""Server blocks from `nginx -T` output: file, server_name, root, listen, fastcgi_pass."""
|
|
113
|
+
servers: list[dict] = []
|
|
114
|
+
current_file = ""
|
|
115
|
+
depth = 0
|
|
116
|
+
server: dict | None = None
|
|
117
|
+
server_depth = 0
|
|
118
|
+
statements: list[str] = []
|
|
119
|
+
for raw in text.splitlines():
|
|
120
|
+
if re.match(r"^# configuration file (.+):\s*$", raw):
|
|
121
|
+
statements.append(raw)
|
|
122
|
+
continue
|
|
123
|
+
body = strip_comment(raw).replace("{", "{\n").replace("}", "\n}\n").replace(";", ";\n")
|
|
124
|
+
statements += [part.strip() for part in body.splitlines() if part.strip()]
|
|
125
|
+
for line in statements:
|
|
126
|
+
marker = re.match(r"^# configuration file (.+):\s*$", line)
|
|
127
|
+
if marker:
|
|
128
|
+
current_file = marker.group(1)
|
|
129
|
+
depth, server = 0, None
|
|
130
|
+
continue
|
|
131
|
+
if server is None and re.match(r"^server\s*\{", line):
|
|
132
|
+
server = {"file": current_file, "serverNames": [], "root": "", "listen": [], "fastcgiPass": []}
|
|
133
|
+
server_depth = depth + 1
|
|
134
|
+
if server is not None:
|
|
135
|
+
for directive, key in (("server_name", "serverNames"), ("listen", "listen"), ("fastcgi_pass", "fastcgiPass")):
|
|
136
|
+
match = re.match(rf"^{directive}\s+([^;]+);", line)
|
|
137
|
+
if match:
|
|
138
|
+
values = match.group(1).split() if directive == "server_name" else [match.group(1).strip()]
|
|
139
|
+
for value in values:
|
|
140
|
+
if value not in server[key]:
|
|
141
|
+
server[key].append(value)
|
|
142
|
+
match = re.match(r"^root\s+([^;]+);", line)
|
|
143
|
+
if match and not server["root"]:
|
|
144
|
+
server["root"] = match.group(1).strip()
|
|
145
|
+
depth += line.count("{") - line.count("}")
|
|
146
|
+
if server is not None and depth < server_depth:
|
|
147
|
+
servers.append(server)
|
|
148
|
+
server = None
|
|
149
|
+
return servers
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def version_tuple(value: str) -> tuple[int, ...]:
|
|
153
|
+
return tuple(int(part) for part in re.findall(r"\d+", value)[:3])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def php_line(value: str) -> str:
|
|
157
|
+
match = re.search(r"(\d+)\.(\d+)", value)
|
|
158
|
+
return f"{match.group(1)}.{match.group(2)}" if match else ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_containers(text: str) -> list[dict]:
|
|
162
|
+
containers = []
|
|
163
|
+
for line in text.splitlines():
|
|
164
|
+
parts = line.split("\t")
|
|
165
|
+
if len(parts) < 2 or not parts[0].strip():
|
|
166
|
+
continue
|
|
167
|
+
name, image = parts[0].strip(), parts[1].strip()
|
|
168
|
+
ports = parts[2] if len(parts) > 2 else ""
|
|
169
|
+
bound = re.findall(r"(?:([\d.]+|\[::\]):)?(\d+)->(\d+)/tcp", ports)
|
|
170
|
+
containers.append({"name": name, "image": image, "ports": [{"host": host or "0.0.0.0", "hostPort": int(hp), "containerPort": int(cp)} for host, hp, cp in bound], "mounts": parts[3].strip() if len(parts) > 3 else ""})
|
|
171
|
+
return containers
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def image_info(image: str) -> dict:
|
|
175
|
+
repo, _, tag = image.rpartition(":") if ":" in image.split("/")[-1] else (image, "", "latest")
|
|
176
|
+
name = repo.split("/")[-1]
|
|
177
|
+
php = re.search(r"(?:^|php)(\d+\.\d+(?:\.\d+)?)", tag)
|
|
178
|
+
version = php.group(1) if php and (name == "php" or "php" in tag) else ""
|
|
179
|
+
return {"repository": name, "tag": tag, "php": version, "line": php_line(version), "patchPinned": version.count(".") == 2, "isPhp": bool(PHP_IMAGE.search(image))}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def unit_findings(units: dict[str, str]) -> list[dict]:
|
|
183
|
+
findings = []
|
|
184
|
+
for path, body in units.items():
|
|
185
|
+
for match in re.finditer(r"(?:docker|podman) run[^\n]*", re.sub(r"\\\s*\n", " ", body)):
|
|
186
|
+
command = match.group(0)
|
|
187
|
+
image = next((token for token in command.split() if PHP_IMAGE.search(token) and ":" in token), "")
|
|
188
|
+
if image and "--entrypoint docker-php-entrypoint" not in command and "--entrypoint=docker-php-entrypoint" not in command:
|
|
189
|
+
findings.append({"kind": "php-image-entrypoint", "severity": "error", "unit": path, "image": image,
|
|
190
|
+
"message": f"{path} runs {image} with the image's own entrypoint; add --entrypoint docker-php-entrypoint and an explicit php-fpm command (the WordPress entrypoint copies WordPress into a mounted folder without wp-config.php)"})
|
|
191
|
+
return findings
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def runtime_status(version: str, advisory: dict | None) -> str:
|
|
195
|
+
if not advisory:
|
|
196
|
+
return "not-checked"
|
|
197
|
+
line = php_line(version)
|
|
198
|
+
fixed = (advisory.get("fixed") or {}).get(line)
|
|
199
|
+
if not line:
|
|
200
|
+
return "unknown"
|
|
201
|
+
if not fixed:
|
|
202
|
+
lines = sorted((advisory.get("fixed") or {}), key=version_tuple)
|
|
203
|
+
if line in (advisory.get("unsupportedLines") or []) or (lines and version_tuple(line) < version_tuple(lines[0])):
|
|
204
|
+
return "affected-no-fix"
|
|
205
|
+
return "not-listed"
|
|
206
|
+
if version.count(".") < 2:
|
|
207
|
+
return "unknown-patch"
|
|
208
|
+
return "affected" if version_tuple(version) < version_tuple(fixed) else "fixed"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def resolve_runtime(target: str, host_php: str, fpm_units: list[str], containers: list[dict]) -> dict:
|
|
212
|
+
socket = re.search(r"php(\d+\.\d+)-fpm", target)
|
|
213
|
+
if target.startswith("unix:") and socket:
|
|
214
|
+
line = socket.group(1)
|
|
215
|
+
version = host_php if php_line(host_php) == line else line
|
|
216
|
+
return {"kind": "host-php-fpm", "unit": f"php{line}-fpm", "version": version}
|
|
217
|
+
port = re.search(r"(?:^|:)(\d+)$", target)
|
|
218
|
+
if port:
|
|
219
|
+
for container in containers:
|
|
220
|
+
if any(p["hostPort"] == int(port.group(1)) for p in container["ports"]):
|
|
221
|
+
info = image_info(container["image"])
|
|
222
|
+
return {"kind": "container", "container": container["name"], "image": container["image"], "version": info["php"]}
|
|
223
|
+
if target.startswith("unix:") and fpm_units:
|
|
224
|
+
return {"kind": "host-php-fpm", "unit": fpm_units[0], "version": host_php}
|
|
225
|
+
return {"kind": "unknown", "version": ""}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def site_slug(server: dict) -> str:
|
|
229
|
+
name = next((n for n in server["serverNames"] if n not in ("_", "localhost")), "") or Path(server["file"]).name
|
|
230
|
+
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:40] or "site"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def migration_plan(server: dict, runtime: dict, image: str, port: int, writable: list[str]) -> dict:
|
|
234
|
+
slug = site_slug(server)
|
|
235
|
+
root = server["root"] or "/var/www/SITE"
|
|
236
|
+
container = f"php-{slug}"
|
|
237
|
+
mounts = [f"{root}:{root}:ro"] + [f"{path}:{path}" for path in writable if path.startswith(root)]
|
|
238
|
+
run = ["docker", "run", "-d", "--name", container, "--restart", "unless-stopped",
|
|
239
|
+
"--entrypoint", "docker-php-entrypoint", "--user", "SITE_UID:SITE_GID", "-p", f"127.0.0.1:{port}:9000"]
|
|
240
|
+
for mount in mounts:
|
|
241
|
+
run += ["-v", mount]
|
|
242
|
+
run += [image, "php-fpm"]
|
|
243
|
+
old = runtime.get("target", "")
|
|
244
|
+
host = next((n for n in server["serverNames"] if n not in ("_", "localhost")), "SITE_HOST")
|
|
245
|
+
listing = f"/root/maggie-{slug}-files-before.txt"
|
|
246
|
+
return {
|
|
247
|
+
"site": host, "nginxFile": server["file"], "root": root, "currentRuntime": runtime, "image": image,
|
|
248
|
+
"steps": [
|
|
249
|
+
f"maggie deployment host-lock acquire --host HOST --session SESSION # before any change",
|
|
250
|
+
f"stat -c '%u:%g' {shlex.quote(root)} # SITE_UID:SITE_GID for --user",
|
|
251
|
+
f"find {shlex.quote(root)} -xdev -type f | sort > {listing}",
|
|
252
|
+
f"docker pull {image}",
|
|
253
|
+
" ".join(shlex.quote(part) for part in run),
|
|
254
|
+
f"cp -a {shlex.quote(server['file'])} {shlex.quote(server['file'])}.maggie-bak",
|
|
255
|
+
f"sed -i 's#fastcgi_pass {old};#fastcgi_pass 127.0.0.1:{port};#' {shlex.quote(server['file'])}",
|
|
256
|
+
"nginx -t && systemctl reload nginx",
|
|
257
|
+
],
|
|
258
|
+
"systemdUnit": "If the container is managed by a systemd unit, its ExecStart must carry the same --entrypoint docker-php-entrypoint and php-fpm command; recreate through the unit, not by hand, or the next restart brings the old command back.",
|
|
259
|
+
"verify": [
|
|
260
|
+
f"find {shlex.quote(root)} -xdev -type f | sort | diff {listing} - # must print nothing: no files appeared",
|
|
261
|
+
f"curl -s -o /dev/null -w '%{{http_code}}' https://{host}/wp-admin/setup-config.php # must not be 200 (404 on a site that is not WordPress)",
|
|
262
|
+
f"curl -sI https://{host}/ | head -1 # the site still answers as before",
|
|
263
|
+
f"docker exec {container} php -v | head -1 # the patched version",
|
|
264
|
+
],
|
|
265
|
+
"rollback": [
|
|
266
|
+
f"cp -a {shlex.quote(server['file'])}.maggie-bak {shlex.quote(server['file'])} && nginx -t && systemctl reload nginx",
|
|
267
|
+
f"docker rm -f {container}",
|
|
268
|
+
],
|
|
269
|
+
"mutation": "not executed",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def inventory(evidence_text: str, advisory: dict | None, latest_tags: dict, today: date, eol_table: dict, image_template: str, base_port: int, writable: list[str]) -> dict:
|
|
274
|
+
sections = parse_sections(evidence_text)
|
|
275
|
+
findings: list[dict] = []
|
|
276
|
+
os_info = parse_os(sections.get("os-release", ""))
|
|
277
|
+
if (finding := os_finding(os_info, today, eol_table)):
|
|
278
|
+
findings.append(finding)
|
|
279
|
+
nginx_text = sections.get("nginx-T", "")
|
|
280
|
+
if not nginx_text.strip():
|
|
281
|
+
findings.append({"kind": "nginx-unread", "severity": "warning", "message": "no `nginx -T` output: config discovery must follow sites-enabled symlinks (nginx -T or grep -R, never grep -r)"})
|
|
282
|
+
servers = parse_nginx(nginx_text)
|
|
283
|
+
host_php = (re.search(r"PHP (\d+\.\d+\.\d+)", sections.get("php-v", "")) or [None, ""])[1]
|
|
284
|
+
fpm_units = [line.split()[0] for line in sections.get("php-fpm-units", "").splitlines() if line.strip()]
|
|
285
|
+
containers = parse_containers(sections.get("docker-ps", ""))
|
|
286
|
+
units = {name[5:]: body for name, body in sections.items() if name.startswith("unit:")}
|
|
287
|
+
findings += unit_findings(units)
|
|
288
|
+
for line in sections.get("docker-entrypoints", "").splitlines():
|
|
289
|
+
parts = line.split("\t")
|
|
290
|
+
if len(parts) == 3 and PHP_IMAGE.search(parts[1]) and "docker-php-entrypoint" not in parts[2]:
|
|
291
|
+
findings.append({"kind": "php-image-entrypoint", "severity": "error", "container": parts[0].lstrip("/"), "image": parts[1],
|
|
292
|
+
"message": f"container {parts[0].lstrip('/')} runs {parts[1]} with entrypoint {parts[2]}; recreate it with --entrypoint docker-php-entrypoint and an explicit php-fpm command"})
|
|
293
|
+
for container in containers:
|
|
294
|
+
info = image_info(container["image"])
|
|
295
|
+
newest = ((latest_tags.get(info["repository"]) or {}).get(info["line"]) if info["line"] else None)
|
|
296
|
+
if info["patchPinned"] and newest and version_tuple(info["php"]) < version_tuple(newest):
|
|
297
|
+
findings.append({"kind": "image-stale", "severity": "warning", "container": container["name"], "image": container["image"], "newest": newest,
|
|
298
|
+
"message": f"{container['image']} is pinned to {info['php']}; the newest patch of {info['line']} is {newest}"})
|
|
299
|
+
exposed = [p for p in container["ports"] if p["host"] not in ("127.0.0.1", "::1")]
|
|
300
|
+
if info["isPhp"] and exposed:
|
|
301
|
+
findings.append({"kind": "fpm-port-public", "severity": "error", "container": container["name"], "message": f"{container['name']} publishes php-fpm on {exposed[0]['host']}:{exposed[0]['hostPort']}; bind it to 127.0.0.1"})
|
|
302
|
+
used_ports = {p["hostPort"] for c in containers for p in c["ports"]}
|
|
303
|
+
port = base_port
|
|
304
|
+
sites, plans = [], []
|
|
305
|
+
for server in servers:
|
|
306
|
+
if not server["fastcgiPass"]:
|
|
307
|
+
continue
|
|
308
|
+
for target in server["fastcgiPass"]:
|
|
309
|
+
runtime = resolve_runtime(target, host_php, fpm_units, containers)
|
|
310
|
+
runtime["target"] = target
|
|
311
|
+
status = runtime_status(runtime.get("version", ""), advisory)
|
|
312
|
+
sites.append({"site": next(iter(server["serverNames"]), ""), "nginxFile": server["file"], "root": server["root"], "runtime": runtime, "status": status})
|
|
313
|
+
if status in ("affected", "affected-no-fix", "unknown-patch") and advisory:
|
|
314
|
+
line = php_line(runtime.get("version", ""))
|
|
315
|
+
fixes = advisory.get("fixed") or {}
|
|
316
|
+
fixed = fixes.get(line) or (max(fixes.values(), key=version_tuple) if fixes else line)
|
|
317
|
+
while port in used_ports:
|
|
318
|
+
port += 1
|
|
319
|
+
used_ports.add(port)
|
|
320
|
+
plans.append(migration_plan(server, runtime, image_template.format(version=fixed, line=php_line(fixed)), port, writable))
|
|
321
|
+
affected = [s for s in sites if s["status"] in ("affected", "affected-no-fix")]
|
|
322
|
+
return {
|
|
323
|
+
"schemaVersion": "maggie-host-runtime-inventory.v1",
|
|
324
|
+
"os": os_info, "hostPhp": host_php, "phpFpmUnits": fpm_units, "containers": containers,
|
|
325
|
+
"sites": sites, "advisory": (advisory or {}).get("id"), "affectedSites": len(affected),
|
|
326
|
+
"findings": findings, "migrationPlans": plans,
|
|
327
|
+
"passed": not affected and not any(f["severity"] == "error" for f in findings),
|
|
328
|
+
"mutation": "not executed",
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def collect_ssh(target: str, ssh_args: list[str]) -> str:
|
|
333
|
+
command = ["ssh", "-o", "BatchMode=yes", *ssh_args, target, "bash -s"]
|
|
334
|
+
completed = subprocess.run(command, input=COLLECTOR.encode(), capture_output=True, timeout=300, check=False)
|
|
335
|
+
if completed.returncode not in (0, 1) and not completed.stdout:
|
|
336
|
+
raise OSError(f"collector failed on {target} (exit {completed.returncode})")
|
|
337
|
+
return completed.stdout.decode("utf-8", "replace")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --- host lock -----------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
LOCK_SCRIPT = r"""set -u
|
|
343
|
+
LOCK=$1; ACTION=$2; SESSION=$3; TTL=$4; PURPOSE=$5; FORCE=$6; MINUTES=$7; shift 7
|
|
344
|
+
NOW=$(date +%s)
|
|
345
|
+
exec 9>"$LOCK.guard" || exit 3
|
|
346
|
+
flock -w 10 9 || exit 3
|
|
347
|
+
HOLDER=""; EXPIRES=0
|
|
348
|
+
if [ -f "$LOCK" ]; then HOLDER=$(sed -n 's/^session=//p' "$LOCK"); EXPIRES=$(sed -n 's/^expires=//p' "$LOCK"); fi
|
|
349
|
+
held() { [ -n "$HOLDER" ] && [ "$HOLDER" != "$SESSION" ] && [ "${EXPIRES:-0}" -gt "$NOW" ]; }
|
|
350
|
+
echo "### lock"; [ -f "$LOCK" ] && cat "$LOCK"
|
|
351
|
+
echo "### recent"; find -L "$@" -xdev -type f -mmin "-$MINUTES" 2>/dev/null | head -50
|
|
352
|
+
case "$ACTION" in
|
|
353
|
+
acquire)
|
|
354
|
+
if held; then echo "### result"; echo held; exit 10; fi
|
|
355
|
+
printf 'session=%s\nacquired=%s\nexpires=%s\npurpose=%s\nuser=%s\n' "$SESSION" "$NOW" "$((NOW + TTL))" "$PURPOSE" "$(id -un)" > "$LOCK.tmp" && mv "$LOCK.tmp" "$LOCK"
|
|
356
|
+
echo "### result"; echo acquired;;
|
|
357
|
+
release)
|
|
358
|
+
if held && [ "$FORCE" != 1 ]; then echo "### result"; echo held; exit 10; fi
|
|
359
|
+
rm -f "$LOCK"; echo "### result"; echo released;;
|
|
360
|
+
*) echo "### result"; if held; then echo held; elif [ -n "$HOLDER" ] && [ "${EXPIRES:-0}" -gt "$NOW" ]; then echo mine; else echo free; fi;;
|
|
361
|
+
esac
|
|
362
|
+
"""
|
|
363
|
+
WATCH = ["/etc/systemd/system", "/etc/nginx", "/etc/containers/systemd", "/etc/docker"]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def host_lock(action: str, host: str, session: str, ttl: int, purpose: str, force: bool, minutes: int, lock_path: str, watch: list[str], ssh_args: list[str]) -> dict:
|
|
367
|
+
if not SAFE_NAME.match(session):
|
|
368
|
+
raise ValueError("--session must be 1-64 letters, digits, dot, dash or underscore")
|
|
369
|
+
argv = [lock_path, action, session, str(ttl), re.sub(r"[\n\r]", " ", purpose)[:120], "1" if force else "0", str(minutes), *watch]
|
|
370
|
+
if host == "local":
|
|
371
|
+
command = ["bash", "-c", LOCK_SCRIPT, "lock", *argv]
|
|
372
|
+
else:
|
|
373
|
+
command = ["ssh", "-o", "BatchMode=yes", *ssh_args, host, "bash -c " + shlex.quote(LOCK_SCRIPT) + " lock " + " ".join(shlex.quote(a) for a in argv)]
|
|
374
|
+
completed = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
|
|
375
|
+
if completed.returncode == 3:
|
|
376
|
+
raise OSError("could not take the lock guard (another command is holding it, or the lock folder is not writable)")
|
|
377
|
+
sections = parse_sections(completed.stdout)
|
|
378
|
+
lock = dict(line.split("=", 1) for line in sections.get("lock", "").splitlines() if "=" in line)
|
|
379
|
+
result = sections.get("result", "").strip() or "error"
|
|
380
|
+
if "expires" in lock:
|
|
381
|
+
lock["expiresAt"] = datetime.fromtimestamp(int(lock["expires"]), timezone.utc).isoformat()
|
|
382
|
+
recent = [line for line in sections.get("recent", "").splitlines() if line.strip() and not line.startswith(lock_path)]
|
|
383
|
+
report = {"schemaVersion": "maggie-host-lock.v1", "host": host, "action": action, "session": session, "result": result,
|
|
384
|
+
"lock": lock, "recentChanges": recent, "recentMinutes": minutes,
|
|
385
|
+
"passed": result in ("acquired", "released", "mine", "free")}
|
|
386
|
+
if result == "held":
|
|
387
|
+
report["message"] = f"another session ({lock.get('session')}, {lock.get('purpose') or 'no purpose given'}) holds this host until {lock.get('expiresAt')}; wait, or ask whoever runs it to stop. Do not edit units, containers or nginx meanwhile."
|
|
388
|
+
elif recent:
|
|
389
|
+
report["message"] = f"{len(recent)} file(s) under the watched folders changed in the last {minutes} minutes; make sure each is yours before editing."
|
|
390
|
+
return report
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def main(argv: list[str] | None = None) -> int:
|
|
394
|
+
parser = argparse.ArgumentParser(prog="maggie deployment", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
395
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
396
|
+
ssh_parent = argparse.ArgumentParser(add_help=False)
|
|
397
|
+
ssh_parent.add_argument("--ssh-key")
|
|
398
|
+
ssh_parent.add_argument("--ssh-port", default="22")
|
|
399
|
+
ri = sub.add_parser("runtime-inventory", parents=[ssh_parent], help="read-only PHP/runtime inventory of a host, with an advisory check and migration plans")
|
|
400
|
+
source = ri.add_mutually_exclusive_group()
|
|
401
|
+
source.add_argument("--evidence", type=Path, help="collector output saved from the host")
|
|
402
|
+
source.add_argument("--ssh", help="user@host: run the read-only collector over ssh")
|
|
403
|
+
source.add_argument("--print-collector", action="store_true", help="print the read-only collector script and exit")
|
|
404
|
+
ri.add_argument("--advisory", type=Path, help='JSON: {"id": "CVE-...", "component": "php", "fixed": {"8.1": "8.1.31"}, "unsupportedLines": ["7.4"]}')
|
|
405
|
+
ri.add_argument("--latest-tags", type=Path, help='JSON: newest patch per image line, {"php": {"8.2": "8.2.26"}}')
|
|
406
|
+
ri.add_argument("--eol-table", type=Path, help='JSON added to the built-in table, {"ubuntu": {"25.04": "2026-01-15"}}')
|
|
407
|
+
ri.add_argument("--today", help="YYYY-MM-DD (default: today)")
|
|
408
|
+
ri.add_argument("--image", default="php:{version}-fpm", help="migration image; {version} = fixed version, {line} = its line (e.g. wordpress:php{line}-fpm)")
|
|
409
|
+
ri.add_argument("--base-port", type=int, default=9100, help="first 127.0.0.1 port for new php-fpm containers")
|
|
410
|
+
ri.add_argument("--writable", action="append", default=[], help="folder under the site root the container may write (uploads, cache); everything else is mounted read-only")
|
|
411
|
+
hl = sub.add_parser("host-lock", parents=[ssh_parent], help="one agent session at a time on a production host")
|
|
412
|
+
hl.add_argument("action", choices=("acquire", "status", "release"))
|
|
413
|
+
hl.add_argument("--host", required=True, help='ssh target, or "local"')
|
|
414
|
+
hl.add_argument("--session", required=True, help="this agent session's id")
|
|
415
|
+
hl.add_argument("--ttl", type=int, default=3600, help="seconds the lock lasts without renewal")
|
|
416
|
+
hl.add_argument("--purpose", default="", help="what this session is changing (shown to other sessions)")
|
|
417
|
+
hl.add_argument("--force", action="store_true", help="release a lock held by another session (only after confirming it stopped)")
|
|
418
|
+
hl.add_argument("--recent-minutes", type=int, default=30)
|
|
419
|
+
hl.add_argument("--lock-path", default="/run/lock/maggie-agent-host.lock")
|
|
420
|
+
hl.add_argument("--watch", action="append", default=[], help="folders checked for recent changes (default systemd units, nginx, container units)")
|
|
421
|
+
for command in (ri, hl):
|
|
422
|
+
command.add_argument("--output", type=Path)
|
|
423
|
+
args = parser.parse_args(argv)
|
|
424
|
+
ssh_args = []
|
|
425
|
+
if args.ssh_key:
|
|
426
|
+
ssh_args += ["-i", os.path.expanduser(args.ssh_key)]
|
|
427
|
+
if args.ssh_port != "22":
|
|
428
|
+
ssh_args += ["-p", args.ssh_port]
|
|
429
|
+
try:
|
|
430
|
+
if args.command == "runtime-inventory":
|
|
431
|
+
if args.print_collector:
|
|
432
|
+
print(COLLECTOR)
|
|
433
|
+
return 0
|
|
434
|
+
if not args.evidence and not args.ssh:
|
|
435
|
+
raise ValueError("pass --evidence FILE or --ssh user@host")
|
|
436
|
+
text = args.evidence.read_text(encoding="utf-8", errors="replace") if args.evidence else collect_ssh(args.ssh, ssh_args)
|
|
437
|
+
table = {key: dict(value) for key, value in EOL.items()}
|
|
438
|
+
if args.eol_table:
|
|
439
|
+
for key, value in json.loads(args.eol_table.read_text(encoding="utf-8")).items():
|
|
440
|
+
table.setdefault(key, {}).update(value)
|
|
441
|
+
report = inventory(text, json.loads(args.advisory.read_text(encoding="utf-8")) if args.advisory else None,
|
|
442
|
+
json.loads(args.latest_tags.read_text(encoding="utf-8")) if args.latest_tags else {},
|
|
443
|
+
date.fromisoformat(args.today) if args.today else date.today(), table, args.image, args.base_port, args.writable)
|
|
444
|
+
else:
|
|
445
|
+
report = host_lock(args.action, args.host, args.session, args.ttl, args.purpose, args.force, args.recent_minutes, args.lock_path, args.watch or WATCH, ssh_args)
|
|
446
|
+
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
|
447
|
+
print(f"BLOCKED: maggie deployment {args.command}: {error}", file=sys.stderr)
|
|
448
|
+
return 2
|
|
449
|
+
text = json.dumps(report, indent=2, ensure_ascii=False)
|
|
450
|
+
if args.output:
|
|
451
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
args.output.write_text(text + "\n", encoding="utf-8")
|
|
453
|
+
print(text)
|
|
454
|
+
return 0 if report["passed"] else 1
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
if __name__ == "__main__":
|
|
458
|
+
raise SystemExit(main())
|