fix(apex): preserve environments and restore secure standalone operations
This commit is contained in:
1 parent
6463bad5ee
commit
995ea9bdf9
44 files changed
+1399
-429
No files matched your search
@@ -75,4 +75,4 @@ def run(ctx, args):
|
||||
except Exception as e:
|
||||
log.error(f"{type(e).__name__}: {e}")
|
||||
ctx.notify.error(TITLE, url, f"backup failed: {e}")
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from e
|
||||
+35
-42
@@ -2,40 +2,39 @@
|
||||
"""Orchestrate docker compose across the node.
|
||||
|
||||
Supports targeting all compositions in sequence (default: apex core first, services next;
|
||||
reverse on down) or targeting a specific composition (e.g. `apex compose restart apex`
|
||||
or `apex compose up -d sso`). Exports derived APEX_* vars and layers --env-file (.env secrets + apex.env).
|
||||
reverse on down) or targeting one with `apex compose -p apex restart traefik`.
|
||||
Wrapper options precede the action. Exports derived APEX_* vars and layers env files.
|
||||
"""
|
||||
import os
|
||||
from engine.descriptor import Arg, Flag, Meta, Opt, Rest
|
||||
import shlex
|
||||
|
||||
from engine.descriptor import Flag, Meta, Opt, Rest
|
||||
|
||||
METADATA = Meta(
|
||||
summary="Orchestrate docker compose across the node or on a specific composition.",
|
||||
args=[
|
||||
Opt("-p", "Target composition name or 'all'.", default=None),
|
||||
Flag("--dry-run", "Print commands only."),
|
||||
Arg("action", "up | down | restart | ps | logs | ..."),
|
||||
Rest("extra", "Extra args (target composition name, service names, or docker compose flags)."),
|
||||
Rest("command", "ACTION [COMPOSE_ARGS...]; wrapper options must precede ACTION."),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clean_xray_sockets(ctx):
|
||||
"""Unlink stale host-side Xray sockets in /dev/shm to prevent REALITY panic crash loops."""
|
||||
shm = os.path.join(ctx.paths.stier3, "apex", "xray", "shm")
|
||||
if os.path.isdir(shm):
|
||||
for f in os.listdir(shm):
|
||||
if f.endswith(".sock"):
|
||||
try:
|
||||
os.unlink(os.path.join(shm, f))
|
||||
ctx.log.info(f"Cleaned stale Xray socket: {f}")
|
||||
except OSError as e:
|
||||
ctx.log.warn(f"Failed to unlink {f}: {e}")
|
||||
|
||||
|
||||
def run(ctx, args):
|
||||
log, comp, action = ctx.log, ctx.paths.compositions, args.action
|
||||
extra = [a for a in (args.extra or []) if a != "--dry-run"]
|
||||
dry_run = args.dry_run or len(extra) != len(args.extra or [])
|
||||
log, comp = ctx.log, ctx.paths.compositions
|
||||
command = list(args.command)
|
||||
if command and command[0] == "--":
|
||||
command.pop(0)
|
||||
if not command:
|
||||
log.error("ACTION is required (up, down, restart, logs, ...).")
|
||||
raise SystemExit(64)
|
||||
action, extra = command[0], command[1:]
|
||||
dry_run = args.dry_run
|
||||
before_separator = extra[:extra.index("--")] if "--" in extra else extra
|
||||
if any(arg in ("-p", "--dry-run") or arg.startswith("-p=")
|
||||
or arg.startswith("--dry-run=") for arg in before_separator):
|
||||
log.error("Wrapper options -p and --dry-run must precede ACTION; use -- for passthrough.")
|
||||
raise SystemExit(64)
|
||||
env = dict(os.environ)
|
||||
env.update({k: v for k, v in ctx.vars().items() if k.startswith("APEX_")})
|
||||
|
||||
@@ -49,31 +48,25 @@ def run(ctx, args):
|
||||
]
|
||||
|
||||
target = getattr(args, "p", None)
|
||||
if not target and extra:
|
||||
first = extra[0]
|
||||
if first == "all":
|
||||
target = "all"
|
||||
extra = extra[1:]
|
||||
elif first in available:
|
||||
target = first
|
||||
extra = extra[1:]
|
||||
elif not first.startswith("-"):
|
||||
target = first
|
||||
extra = extra[1:]
|
||||
elif extra[-1] in available and len(extra) > 1:
|
||||
target = extra[-1]
|
||||
extra = extra[:-1]
|
||||
if target is None and extra:
|
||||
if extra[0] in ["all", *available]:
|
||||
target, extra = extra[0], extra[1:]
|
||||
elif action == "up" and len(extra) >= 2 and extra[0] == "-d":
|
||||
# Preserve the documented `up -d apex` spelling without scanning flag values.
|
||||
if extra[1] in ["all", *available]:
|
||||
if len(extra) != 2:
|
||||
log.error("Use -p COMPOSITION before up when adding service arguments.")
|
||||
raise SystemExit(64)
|
||||
target, extra = extra[1], ["-d"]
|
||||
|
||||
if not target:
|
||||
if target is None:
|
||||
target = "all"
|
||||
|
||||
if target != "all" and target not in available:
|
||||
log.error(f"Unknown composition '{target}'. Available: {', '.join(sorted(available)) if available else 'none'}")
|
||||
names = ", ".join(sorted(available)) if available else "none"
|
||||
log.error(f"Unknown composition '{target}'. Available: {names}")
|
||||
raise SystemExit(1)
|
||||
|
||||
if action in ("up", "restart", "start") and target in ("all", "apex") and not dry_run:
|
||||
_clean_xray_sockets(ctx)
|
||||
|
||||
def sequence():
|
||||
if target != "all":
|
||||
return [os.path.join(comp, target)]
|
||||
@@ -89,13 +82,13 @@ def run(ctx, args):
|
||||
if os.path.isfile(os.path.join(dirpath, ef)):
|
||||
base += ["--env-file", ef]
|
||||
if action == "up":
|
||||
up_flags = [] if "-d" in extra else ["-d"]
|
||||
up_flags = [] if any(flag in extra for flag in ("-d", "--detach")) else ["-d"]
|
||||
cmd = base + ["up"] + up_flags + extra
|
||||
else:
|
||||
cmd = base + [action] + extra
|
||||
|
||||
if dry_run:
|
||||
log.info(f"[DRY-RUN] (cd {dirpath} && {' '.join(cmd)})")
|
||||
log.info(f"[DRY-RUN] (cd {shlex.quote(dirpath)} && {shlex.join(cmd)})")
|
||||
else:
|
||||
log.info(f"{action} {name}...")
|
||||
ctx.sys.run(cmd, cwd=dirpath, env=env, check=True)
|
||||
|
||||
@@ -21,7 +21,7 @@ def run(ctx, args):
|
||||
mod = overlay.load_module(r.path, "apex_umbrella_" + name.replace("/", "_"))
|
||||
if getattr(mod, "DISABLED", None):
|
||||
ctx.log.warn(f"Skipping disabled {name}"); continue
|
||||
meta = getattr(mod, "METADATA")
|
||||
meta = mod.METADATA
|
||||
has_dry = any(getattr(a, "name", None) == "--dry-run" for a in meta.args)
|
||||
argv = ["--dry-run"] if (args.dry_run and has_dry) else []
|
||||
sub_args = build_parser(meta, f"apex {name}").parse_args(argv)
|
||||
|
||||
+78
-36
@@ -1,13 +1,85 @@
|
||||
# actions/configure/base.py
|
||||
"""Port of configure/base — base packages + ufw + ufw-docker helper."""
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import urllib.request
|
||||
from engine.descriptor import Meta
|
||||
|
||||
METADATA = Meta(summary="Configure base system packages (curl, wget, git, rsyslog, ufw, ufw-docker).")
|
||||
UFW_DOCKER_URL = "https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker"
|
||||
UFW_DOCKER_REVISION = "020a8699f95592561f254d8d4ad1bb40d401dfc7"
|
||||
UFW_DOCKER_SHA256 = "643e56b080567c567b4aa28650196849b2a2da5dd0473fd3e5216b0886035ab0"
|
||||
UFW_DOCKER_URL = (
|
||||
"https://raw.githubusercontent.com/chaifeng/ufw-docker/"
|
||||
+ UFW_DOCKER_REVISION + "/ufw-docker"
|
||||
)
|
||||
UFW_DOCKER_PATH = Path("/usr/local/bin/ufw-docker")
|
||||
BASE_PACKAGES = ("curl", "wget", "git", "rsyslog", "ufw")
|
||||
|
||||
|
||||
def _install_missing(s, packages):
|
||||
missing = []
|
||||
for package in packages:
|
||||
result = s.run(["dpkg-query", "-W", "-f=${Status}", package],
|
||||
check=False, capture=True)
|
||||
if result.returncode or result.stdout.split()[1:] != ["ok", "installed"]:
|
||||
missing.append(package)
|
||||
if not missing:
|
||||
return
|
||||
apt = ["env", "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive", "apt-get",
|
||||
"-o", "DPkg::Lock::Timeout=60", "-o", "Acquire::Retries=2",
|
||||
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"]
|
||||
s.sudo([*apt, "update", "--error-on=any"])
|
||||
command = [*apt, "install", "-y", "--no-install-recommends", "--no-upgrade",
|
||||
"--no-remove", *missing]
|
||||
plan = s.sudo([*command, "--simulate"], capture=True)
|
||||
if any(line.startswith("Remv ") or re.match(r"^Inst \S+ \[", line)
|
||||
for line in plan.stdout.splitlines()):
|
||||
raise RuntimeError("Missing packages require upgrades/removals; reconcile packages first.")
|
||||
s.sudo(command)
|
||||
|
||||
|
||||
def _check_helper_path(path):
|
||||
for parent in reversed(path.parents):
|
||||
info = parent.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or
|
||||
info.st_mode & 0o022):
|
||||
raise RuntimeError(f"Unsafe ufw-docker ancestor: {parent}")
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 or
|
||||
info.st_mode & 0o022 or info.st_nlink != 1):
|
||||
raise RuntimeError(f"Unsafe ufw-docker target: {path}")
|
||||
return True
|
||||
|
||||
|
||||
def _install_helper(ctx):
|
||||
path = UFW_DOCKER_PATH
|
||||
exists = _check_helper_path(path)
|
||||
payload = path.read_bytes() if exists else None
|
||||
if payload is None or hashlib.sha256(payload).hexdigest() != UFW_DOCKER_SHA256:
|
||||
ctx.log.info(f"Downloading pinned ufw-docker helper to {path}...")
|
||||
# The immutable HTTPS URL is authenticated by the digest below.
|
||||
with urllib.request.urlopen(UFW_DOCKER_URL, timeout=30) as response: # noqa: S310
|
||||
payload = response.read(1024 * 1024 + 1)
|
||||
if len(payload) > 1024 * 1024 or hashlib.sha256(payload).hexdigest() != UFW_DOCKER_SHA256:
|
||||
raise RuntimeError("ufw-docker checksum mismatch; refusing installation.")
|
||||
_check_helper_path(path)
|
||||
ctx.host.write_file(str(path), payload.decode("utf-8"), mode="0755",
|
||||
backup=False, sudo=True)
|
||||
elif stat.S_IMODE(path.stat().st_mode) != 0o755:
|
||||
ctx.host.write_file(str(path), payload.decode("utf-8"), mode="0755",
|
||||
backup=False, sudo=True)
|
||||
if (not _check_helper_path(path) or
|
||||
hashlib.sha256(path.read_bytes()).hexdigest() != UFW_DOCKER_SHA256 or
|
||||
stat.S_IMODE(path.stat().st_mode) != 0o755):
|
||||
raise RuntimeError("ufw-docker verification failed; refusing execution.")
|
||||
ctx.log.info("Ensuring ufw-docker routing rules are installed in UFW...")
|
||||
ctx.sys.sudo([str(path), "install"])
|
||||
|
||||
|
||||
def run(ctx, args):
|
||||
@@ -16,14 +88,8 @@ def run(ctx, args):
|
||||
if not os.path.isfile("/etc/debian_version"):
|
||||
log.error("Requires a Debian-based system (Debian/Ubuntu).")
|
||||
raise SystemExit(1)
|
||||
needed = [p for p in ["curl", "wget", "git", "rsyslog"] if not shutil.which(p)]
|
||||
if needed:
|
||||
log.info("Updating package lists...")
|
||||
s.sudo(["apt-get", "update", "-y"])
|
||||
log.info(f"Installing missing essential packages: {', '.join(needed)}...")
|
||||
s.sudo(["apt-get", "install", "-y", "-o", "DPkg::Lock::Timeout=60", *needed])
|
||||
else:
|
||||
log.info("Essential packages already installed.")
|
||||
_check_helper_path(UFW_DOCKER_PATH)
|
||||
_install_missing(s, BASE_PACKAGES)
|
||||
s.ensure_running("rsyslog")
|
||||
|
||||
# Configure APT to discard downloaded archives
|
||||
@@ -61,29 +127,5 @@ def run(ctx, args):
|
||||
s.sudo(["tee", journal_conf], input=journal_content)
|
||||
s.sudo(["systemctl", "restart", "systemd-journald"])
|
||||
|
||||
if not shutil.which("ufw"):
|
||||
log.info("Installing UFW...")
|
||||
s.sudo(["apt-get", "install", "-y", "-o", "DPkg::Lock::Timeout=60", "ufw"])
|
||||
else:
|
||||
log.info("UFW already installed.")
|
||||
|
||||
target_bin = "/usr/local/bin/ufw-docker"
|
||||
if not (os.path.isfile(target_bin) and os.access(target_bin, os.X_OK)):
|
||||
log.info("Downloading ufw-docker helper to /usr/local/bin/ufw-docker...")
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tf:
|
||||
tmp_name = tf.name
|
||||
try:
|
||||
urllib.request.urlretrieve(UFW_DOCKER_URL, tmp_name)
|
||||
s.sudo(["cp", tmp_name, target_bin])
|
||||
s.sudo(["chown", "root:root", target_bin])
|
||||
s.sudo(["chmod", "0755", target_bin])
|
||||
log.success(f"ufw-docker installed to {target_bin}.")
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
else:
|
||||
log.info(f"ufw-docker already exists at {target_bin}.")
|
||||
|
||||
log.info("Ensuring ufw-docker routing rules are installed in UFW...")
|
||||
s.sudo([target_bin, "install"])
|
||||
_install_helper(ctx)
|
||||
log.success("Base system initialization completed.")
|
||||
@@ -18,7 +18,7 @@ def run(ctx, args):
|
||||
ZoneInfo(zone)
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
ctx.log.error("configs/timezone must name an installed IANA timezone.")
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from None
|
||||
current = ctx.sys.run(["timedatectl", "show", "--property=Timezone", "--value"], capture=True).stdout.strip()
|
||||
if current == zone:
|
||||
ctx.log.info(f"Timezone already set to {zone}.")
|
||||
|
||||
+66
-38
@@ -1,54 +1,82 @@
|
||||
# actions/env.py
|
||||
"""Export or synchronize synthesized APEX_* environment variables.
|
||||
|
||||
Outputs shell export statements (for `eval $(apex env)`) or writes a .env file
|
||||
for direct compatibility with bare `docker compose` commands.
|
||||
"""
|
||||
"""Export synthesized APEX values or bridge a composition's environment for Compose."""
|
||||
import os
|
||||
import shlex
|
||||
|
||||
from engine.descriptor import Flag, Meta, Opt
|
||||
|
||||
METADATA = Meta(
|
||||
summary="Export or sync synthesized APEX_* variables for shell or docker compose.",
|
||||
args=[
|
||||
Flag("--export", "Format as shell export statements (default)."),
|
||||
Flag("--dotenv", "Format as raw KEY=VAL lines without 'export'."),
|
||||
Opt("--write", "Write variables to the specified file path.", default=None),
|
||||
Flag("--sync", "Sync variables into compositions/apex/.env for bare docker compose."),
|
||||
Flag("--dotenv", "Print quoted generated APEX_* dotenv values only."),
|
||||
Opt("--write", "Write a standalone generated dotenv file (0600).", default=None),
|
||||
Flag("--sync", "Preserve user .env text and refresh the core Compose managed block."),
|
||||
],
|
||||
)
|
||||
|
||||
_START = "# BEGIN APEX MANAGED ENV"
|
||||
_END = "# END APEX MANAGED ENV"
|
||||
|
||||
|
||||
def _dotenv_value(value):
|
||||
"""Quote a literal using Compose dotenv escapes, including interpolation dollars."""
|
||||
value = str(value)
|
||||
for old, new in (("\\", "\\\\"), ('"', '\\"'), ("$", "$$"),
|
||||
("\r", "\\r"), ("\n", "\\n"), ("\t", "\\t")):
|
||||
value = value.replace(old, new)
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
def _read_optional(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", newline="") as source:
|
||||
return source.read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
|
||||
def _sync_body(existing, public, generated):
|
||||
if any(marker in public or marker in generated for marker in (_START, _END)):
|
||||
raise ValueError("Managed environment marker collides with source content.")
|
||||
lines = existing.splitlines(keepends=True)
|
||||
markers = [(index, line.rstrip("\r\n")) for index, line in enumerate(lines)
|
||||
if _START in line or _END in line]
|
||||
if markers:
|
||||
if (len(markers) != 2 or markers[0][1] != _START or markers[1][1] != _END):
|
||||
raise ValueError("Malformed or duplicate managed environment markers; .env unchanged.")
|
||||
start, end = markers[0][0], markers[1][0]
|
||||
existing = "".join(lines[:start] + lines[end + 1:])
|
||||
# Keep user bytes in order, but place managed values last to preserve precedence.
|
||||
separator = "" if not existing or existing.endswith("\n") else "\n"
|
||||
public_separator = "" if not public or public.endswith("\n") else "\n"
|
||||
return (existing + separator + _START + "\n" + public + public_separator
|
||||
+ generated + _END + "\n")
|
||||
|
||||
|
||||
def run(ctx, args):
|
||||
log = ctx.log
|
||||
vars_dict = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}
|
||||
|
||||
target_file = None
|
||||
modes = (args.export, args.dotenv, args.write is not None, args.sync)
|
||||
if sum(bool(mode) for mode in modes) > 1:
|
||||
ctx.log.error("Choose only one of --export, --dotenv, --write, or --sync.")
|
||||
raise SystemExit(64)
|
||||
variables = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}
|
||||
generated = "".join(f"{key}={_dotenv_value(value)}\n"
|
||||
for key, value in sorted(variables.items()))
|
||||
target = args.write
|
||||
if args.sync:
|
||||
target_file = os.path.join(ctx.paths.compositions, "apex", ".env")
|
||||
elif args.write:
|
||||
target_file = args.write
|
||||
|
||||
if target_file:
|
||||
content = [
|
||||
"# Generated by apex env. Do not edit directly.",
|
||||
"# Contains synthesized node and path scalars for docker compose interpolation.",
|
||||
]
|
||||
for k in sorted(vars_dict.keys()):
|
||||
content.append(f"{k}={vars_dict[k]}")
|
||||
content.append("")
|
||||
body = "\n".join(content)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(target_file)), exist_ok=True)
|
||||
ctx.host.write_file(target_file, body, mode="0600")
|
||||
log.success(f"Synced {len(vars_dict)} variables to {target_file}.")
|
||||
return 0
|
||||
|
||||
if args.dotenv:
|
||||
for k in sorted(vars_dict.keys()):
|
||||
print(f"{k}={vars_dict[k]}")
|
||||
core = os.path.join(ctx.paths.compositions, "apex")
|
||||
target = os.path.join(core, ".env")
|
||||
try:
|
||||
generated = _sync_body(_read_optional(target),
|
||||
_read_optional(os.path.join(core, "apex.env")), generated)
|
||||
except ValueError as error:
|
||||
ctx.log.error(str(error))
|
||||
raise SystemExit(65) from error
|
||||
if target is not None:
|
||||
ctx.host.write_file(target, generated, mode="0600")
|
||||
ctx.log.success(f"Wrote {len(variables)} synthesized variables to {target}.")
|
||||
elif args.dotenv:
|
||||
print(generated, end="")
|
||||
else:
|
||||
for k in sorted(vars_dict.keys()):
|
||||
print(f"export {k}={shlex.quote(str(vars_dict[k]))}")
|
||||
|
||||
for key, value in sorted(variables.items()):
|
||||
print(f"export {key}={shlex.quote(str(value))}")
|
||||
return 0
|
||||
@@ -220,7 +220,7 @@ def run(ctx, args):
|
||||
except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram
|
||||
log.error(f"{type(e).__name__}: {e}")
|
||||
ctx.notify.error(TITLE, url, f"Critical error: {e}")
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
def _check(ctx, args, log, node, url, s):
|
||||
if not os.path.isfile("/etc/debian_version"):
|
||||
|
||||
@@ -61,4 +61,4 @@ def run(ctx, args):
|
||||
except Exception as e:
|
||||
log.error(f"{type(e).__name__}: {e}")
|
||||
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from e
|
||||
@@ -33,4 +33,4 @@ def run(ctx, args):
|
||||
ctx.notify.success(TITLE, url, f"Certificates extracted for {node}")
|
||||
except Exception as e:
|
||||
log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}")
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(1) from e
|
||||
Reference in new issue
Block a user