9 Commits
Author SHA1 Message Date
deytenit bd973d783d refactor: make engine domain-agnostic — identity from node.env, no hardcoded domain
Drop the hardcoded-domain FQDN regex and every hardcoded domain from commons.
Node identity is now explicit in node.env: APEX_NODE_FQDN (real FQDN,
traefik Host()), APEX_NODE_HOST (public host name — backup --host,
obs/notify instance), APEX_SUBNET. The short name (log prefix, sync/<node>
branch, messages) is derived as the first host label.

notify instance is just the host (no hardcoded prefix); the git bot identity
moves to APEX_GIT_AUTHOR_NAME/EMAIL (node.env) with generic defaults. Alloy
node+instance labels use APEX_NODE_HOST. Fallbacks keep a node working if it
omits a var (host<-fqdn<-hostname).

BREAKING CHANGE: removes APEX_NODE and APEX_CLUSTER env vars; node name is now derived from APEX_NODE_FQDN
2026-07-08 09:32:20 +03:00
deytenit 53d3bedca6 feat: add resticontainer backup labels for traefik/adguard/xray
Per the "Core Apex (all nodes)" policy: traefik /etc/traefik/dynamic,
adguard /opt/adguardhome/conf, xray /etc/xray/config.jsonc. Profile-gated
services are only discovered on nodes that run them.
2026-07-07 21:38:25 +03:00
deytenit c100db57fe fix: mount /srv in resticontainer so absolute tierN symlinks resolve
The tierN bind mounts are absolute symlinks into /srv (compositions/<p>/tierN ->
/srv/com-*-tierN/<p>). Under /:/hostfs those targets are dangling inside the
container (restic reported "does not exist, skipping" for every path). Mount
/srv:/srv:ro at the same path with RESTIC_HOSTFS=/ so restic follows the
symlinks and records the real host paths.
2026-07-07 21:15:15 +03:00
deytenit 07a53b9b2f feat: migrate to resticontainer with label-driven per-service backup
Swap the restic core service for resticontainer
(ghcr.io/deytenit/resticontainer:0.19.1): per-service backup intent via
restic.* compose labels (paths / stop / pre+post hooks), docker socket +
/:/hostfs:ro to resolve container mounts to host paths and snapshot the
union in one restic run. backup/run drops explicit /data/tier1,2 args
(paths now come from labels), keeps init/forget/notify, and guards on a
fresh snapshot. Snapshots are stamped --host <node>.apex.ermnvldmr.com
--tag biweekly and written with --compression max.
2026-07-07 20:37:31 +03:00
deytenit b41fc6097f refactor: replace loki store with Grafana Alloy push agent 2026-07-06 14:34:09 +03:00
deytenit 334ca196e0 feat: add .apex-root-owned opt-out for root-owned tier compositions
An empty .apex-root-owned marker in a composition dir makes tiers/useradd
skip its noroot user + .env UID injection, and tiers/chown force its tier
data (dir + children, recursively) to root:root instead of noroot-<proj>.
For services that run as root and reject non-root-owned data (e.g. seafile-mc).
2026-07-06 10:39:04 +03:00
deytenit 0b42ab1bfc docs: link published documentation in README 2026-07-05 12:40:17 +03:00
deytenit ce93f879e7 refactor: gate xray behind profile, expose loki via basicauth, move happ generator to daedalus
- xray gets profiles: [xray] — daedalus/icarus opt in via COMPOSE_PROFILES,
  morpheus never runs it.
- loki is routed at /loki on the node FQDN behind basicauth
  (APEX_LOKI_BASIC_AUTH from node .env); /loki is loki's native API prefix
  so no strip-prefix needed.
- utils/generate-happ-subscriptions + source/ move to daedalus proprietaries;
  commons keeps only what every node shares (16 actions now).
2026-07-05 12:16:51 +03:00
deytenit 1bc448961a fix: update sync/repository rebase target from trunk to main 2026-07-05 12:04:24 +03:00
20 changed files with 374 additions and 475 deletions

No files matched your search

+26 -7
View File
@@ -12,21 +12,22 @@ action to its bash source and the behaviors that must match.
| configure/systemd | actions/configure/systemd | render each unit -> /etc/systemd/system; daemon-reload; enable+start .service |
| configure/routing | actions/configure/routing | ip_forward=1; fwmark 0x1->table100 rule; local default route table100 (idempotent) |
| configure | (new umbrella) | run base,ufw,crowdsec,cron,systemd,routing in order |
| sync/repository | actions/sync/repository | sync/<node> branch; add -A + commons submodule; commit; rebase origin/trunk; force-with-lease; telegram |
| sync/repository | actions/sync/repository | sync/<node> branch; add -A + commons submodule; commit; rebase origin/main (was trunk in the monorepo); force-with-lease; telegram |
| sync/packages | actions/sync/packages | apt --just-print upgrade Inst list; docker+skopeo digest compare; telegram info |
| backup/run | actions/sync/tiers (restic-backup) | init-if-needed; backup /data/@tier1,2 --host --tag com-ermnvldmr-root-<node>; forget --prune 7/4/12; telegram |
| backup/run | actions/sync/tiers (restic-backup) | init-if-needed; label-driven backup via resticontainer (discovers `restic.*`-labelled services, runs pre-hooks/stops, snapshots the resolved host paths) --host $APEX_NODE_HOST --tag biweekly --compression max; guard on a fresh snapshot; forget --prune 7/4/12; telegram |
| tiers/link | actions/tiers/link | node + per-project @tierN symlinks; shared/ links; .env symlink |
| tiers/useradd | actions/tiers/useradd | noroot-<proj> system users; noroot-shared group; APEX_UID/GID block in .env |
| tiers/chown | actions/tiers/chown | {tier}/{proj}=uid:gid; {tier}/shared/{proj}=uid:sgid+sticky; base=root:root |
| utils/extract-traefik-certs | actions/utils/extract-traefik-certs | traefik-certs-dumper v3; 600 perms; chown caller |
| utils/generate-happ-subscriptions | actions/utils/generate-happ-subscriptions | build image; run generator input->output |
| utils/generate-happ-subscriptions | actions/utils/generate-happ-subscriptions | MOVED to daedalus proprietaries (node-specific tooling; commons keeps only shared stuff) |
| utils/lint-docker-compose | actions/utils/lint-docker-compose | dclint --fix via docker; --hook staged-only + restage; + anchor-IP check |
| compose | actions/compose | apex core first then services alpha; reverse on down; --dry-run; extra passthrough |
## Deliberate deltas (rename + fixes)
- root->apex everywhere (CLI, container names apex-*, telegram instance com.ermnvldmr.apex.*, ufw markers APEX.*, networks direct/enclave/socket).
- root->apex everywhere (CLI, container names apex-*, telegram instance = APEX_NODE_HOST, ufw markers APEX.*, networks direct/enclave/socket).
- No <node> arg; identity from FQDN + node.env.
- restic core service gains read-only /data/@tier1,2 mounts (makes existing backup command functional).
- backup superseded by the resticontainer migration (see below): the old whole-tier-root
restic snapshot is replaced by per-service, `restic.*`-label-driven backups.
- ufw markers self-heal (delete ROOT + APEX blocks before inject).
- envsubst -> string.Template.safe_substitute; wget/curl download -> urllib. Unset template
vars stay LITERAL (envsubst emptied them) — typos surface instead of silently blanking.
@@ -48,10 +49,28 @@ action to its bash source and the behaviors that must match.
restored via an icarus-only override layered in the node repo's include (Phase 2).
- icarus: ACME propagation-tuning flags and DEBUG log level drop to the shared defaults;
traefik now runs noroot (APEX_UID) with file provider + HTTP/3 like daedalus.
- morpheus: gains apex-xray (design: xray is a core service on every node) — a config
must exist at `${APEX_TIER1}/xray/data/config.jsonc` before cutover or it crash-loops.
- xray is profile-gated (`xray` in COMPOSE_PROFILES): daedalus + icarus opt in,
morpheus does not (it never ran xray — owner decision 2026-07-05).
- loki (profile `loki`, no node opts in yet) is exposed via traefik at
`https://<node-fqdn>/loki` behind basicauth (`APEX_LOKI_BASIC_AUTH` in the node .env).
- morpheus: dashboard auth changes basicauth (TRAEFIK_INTERNAL_API_AUTH) -> enclave IP
allowlist; the adguard router labels exist without a backend (502, was 404, on /adguard).
- restic: per-node selective tier2 mounts replaced by whole-tier-root RO mounts
(superset; snapshot paths preserved); RESTIC_COMPRESSION per-node via
`APEX_RESTIC_COMPRESSION` (default auto).
## resticontainer migration (SP4 realized)
- The `restic` core service is replaced by `resticontainer`
(`ghcr.io/deytenit/resticontainer`), a restic wrapper that reads per-service
`restic.*` compose labels: `restic.enable`, `restic.backup.paths` (comma-separated
container mount destinations — matched exactly, whole mount dir backed up),
`restic.backup.stop`, and `restic.hooks.pre-backup`/`post-backup` (run via docker exec).
- It mounts the docker socket (list/inspect/exec/stop/start) and `/srv:/srv:ro`
(`RESTIC_HOSTFS=/`); apex data + checkouts live under /srv and the `@tierN` mounts
are absolute symlinks into /srv, so mounting /srv at the same path lets restic
follow them and record the real host paths, and snapshots the union in one run.
- The whole-tier-root RO mounts and the explicit `/data/@tier1,2` backup args are gone;
a service is backed up only if it carries `restic.enable=true`. DB consistency is
per-service: a pre-backup hook dumps into a dedicated backed-up mount (e.g. postgres
`pg_dump … > /dumps/dump.sql`), never the live data dir. `backup/run` now guards on a
fresh snapshot landing for the host (a no-label repo would otherwise be a silent no-op).
+9 -5
View File
@@ -1,7 +1,9 @@
# apex.ermnvldmr.com
# apex
The **commons** of the apex fleet: a stock-python engine (`apex <group>/<action>`) and
The **commons** of an apex fleet: a stock-python engine (`apex <group>/<action>`) and
the shared core docker-compose, consumed by every node as the `commons/` git submodule.
The framework assumes no particular domain — every node declares its own identity in
`node.env`.
## Layout
- `apex` — launcher (python3, stdlib only); node `init.sh` symlinks it onto `PATH`.
@@ -9,12 +11,14 @@ the shared core docker-compose, consumed by every node as the `commons/` git sub
- `actions/` — grouped actions; the path IS the name (`configure/ufw`, `tiers/link`, …).
- `compositions/apex/docker-compose.yml` — the shared, `${env}`-parameterized core compose.
- `vendor/` — pinned pure-python third-party (empty; see `vendor/VENDORED.md`).
- `source/` — independent containerized tools (happ subscription generator).
- `tests/` — stdlib `unittest` suite (`tests/run.sh`).
Node-specific tooling lives in each node repo's `proprietaries/` — commons holds only
what every node shares.
## Usage (on a node)
`apex` acts on the **local checkout** — no `<node>` argument. Identity comes from the
host FQDN (`<node>.a<x>.apex.ermnvldmr.com`) and `node.env`.
`apex` acts on the **local checkout** — no `<node>` argument. Identity comes from
`node.env` (`APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET`).
- `apex` / `apex --help` — list actions
- `apex configure/ufw` — render + apply this node's ufw config
- `apex configure` — run every `configure/*` in order
+41 -14
View File
@@ -1,17 +1,23 @@
# actions/backup/run.py
"""Port of sync/tiers restic-backup (SP4: behavior-identical). Runs the restic core
service one-shot: init-if-needed -> backup tiers -> forget --prune -> Telegram."""
"""Label-driven restic backup via resticontainer (github.com/deytenit/resticontainer).
Runs the resticontainer core service one-shot: init-if-needed -> backup (discovers
`restic.*`-labelled services, runs their pre-hooks/stops, snapshots the union of the
resolved host paths in a single restic run) -> forget --prune -> Telegram. Every
non-`backup` subcommand passes straight through to restic, so init/snapshots/forget
behave exactly as before."""
import json
import os
from engine.descriptor import Meta, Arg
METADATA = Meta(summary="Run restic backup of the backed-up tiers (init-if-needed, backup, forget --prune).",
METADATA = Meta(summary="Run the resticontainer label-driven backup (init-if-needed, backup, forget --prune).",
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
TITLE = "Restic Host Backup"
def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
core = ctx.paths.core
tag = f"com-ermnvldmr-root-{node}" # KEPT unchanged for backup continuity (SP4/Decision D)
host = ctx.node.host # restic --host: the node's public host name (APEX_NODE_HOST)
tag = "biweekly" # restic --tag: snapshot cadence (this cron fires on the 5th & 20th)
if not os.path.isfile(os.path.join(core, "docker-compose.yml")):
log.error(f"Core compose not found in {core}"); raise SystemExit(1)
env = dict(os.environ, COMPOSE_PROFILES="manual")
@@ -21,30 +27,51 @@ def run(ctx, args):
if os.path.isfile(os.path.join(core, ef)):
envfiles += ["--env-file", ef]
def restic(*cmd, check=True):
def restic(*cmd, check=True, capture=False):
return ctx.sys.run(["docker", "compose", *envfiles, "--profile", "manual",
"run", "--rm", "restic", *cmd], cwd=core, env=env, check=check)
"run", "--rm", "-T", "resticontainer", *cmd],
cwd=core, env=env, check=check, capture=capture)
def host_snapshot_ids():
"""This host's snapshot IDs, or None if it can't be determined (never fail on this)."""
cp = restic("snapshots", "--host", host, "--json", check=False, capture=True)
if cp.returncode != 0:
return None
try:
return {s.get("id") for s in json.loads(cp.stdout or "[]")}
except (ValueError, TypeError):
return None
try:
log.info("Checking restic repository availability...")
if restic("snapshots", check=False).returncode == 0:
log.info("Restic repository reachable.")
elif restic("init", check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic init failed", node); raise SystemExit(1)
log.info("Backing up /data/@tier1 and /data/@tier2 ...")
if restic("backup", "/data/@tier1", "/data/@tier2", "--host", node, "--tag", tag,
"--compression", "auto", check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic backup failed", node); raise SystemExit(1)
ctx.notify.error(TITLE, url, "restic init failed"); raise SystemExit(1)
before = host_snapshot_ids()
log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...")
if restic("backup", "--host", host, "--tag", tag, "--compression", "max",
check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic backup failed"); raise SystemExit(1)
# Guard: the label-driven backup is a silent no-op if no service carries the
# `restic.enable` label (resticontainer finds zero paths and skips restic).
# Prove a fresh snapshot for this host actually landed before we prune/notify.
after = host_snapshot_ids()
if before is not None and after is not None and not (after - before):
log.error("No new snapshot produced — refusing to report success.")
ctx.notify.error(TITLE, url,
"backup produced no new snapshot (no restic.* labels discovered?)")
raise SystemExit(1)
log.info("Applying retention (forget --prune)...")
if restic("forget", "--prune", "--keep-daily", "7", "--keep-weekly", "4",
"--keep-monthly", "12", check=False).returncode != 0:
ctx.notify.error(TITLE, url, "restic forget/prune failed", node); raise SystemExit(1)
ctx.notify.error(TITLE, url, "restic forget/prune failed"); raise SystemExit(1)
log.success("Restic backup completed.")
if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}", node):
if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}"):
raise SystemExit(1) # bash exited 1 when the success telegram failed to send
except SystemExit:
raise
except Exception as e:
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"backup failed: {e}", node)
ctx.notify.error(TITLE, url, f"backup failed: {e}")
raise SystemExit(1)
+3 -3
View File
@@ -15,13 +15,13 @@ def run(ctx, args):
raise
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}", node)
ctx.notify.error(TITLE, url, f"Critical error: {e}")
raise SystemExit(1)
def _check(ctx, args, log, node, url, s):
if not os.path.isfile("/etc/debian_version"):
log.error("Requires a Debian-based system.")
ctx.notify.error(TITLE, url, "Requires a Debian-based system.", node)
ctx.notify.error(TITLE, url, "Requires a Debian-based system.")
raise SystemExit(1)
log.info("Updating package lists...")
@@ -66,6 +66,6 @@ def _check(ctx, args, log, node, url, s):
images = (f"*Images ({len(outdated)}):* " + ", ".join(f"`{i}`" for i in outdated)) if outdated else "*Images:* _none_"
# A failed send exits 1 (bash: telegram_info's return 1 tripped set -e).
if not ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}", node):
if not ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}"):
raise SystemExit(1)
log.success("Security update check completed.")
+13 -9
View File
@@ -1,19 +1,23 @@
# actions/sync/repository.py
"""Port of sync/repository — capture-up commit + rebase onto origin/trunk + force-push."""
"""Port of sync/repository — capture-up commit + rebase onto origin/main + force-push."""
import os
from datetime import datetime
from engine.descriptor import Meta, Arg
from engine.identity import read_env
METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/trunk, force-push sync/<node>.",
METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/<node>.",
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
TITLE = "Annual Remote Sync"
def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
repo, commons = ctx.paths.repo_root, ctx.paths.commons
ne = read_env(os.path.join(repo, "node.env")) # per-node bot identity (generic defaults)
author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]")
email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost")
env = dict(os.environ,
GIT_AUTHOR_NAME="Adam Jensen [bot]", GIT_AUTHOR_EMAIL="adam@ermnvldmr.com",
GIT_COMMITTER_NAME="Adam Jensen [bot]", GIT_COMMITTER_EMAIL="adam@ermnvldmr.com")
GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email)
def git(*a, check=True):
return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env)
@@ -41,20 +45,20 @@ def run(ctx, args):
git("commit", "-m", f"[{node}] Auto-commit: {ts}")
else:
log.info("No changes to commit.")
git("fetch", "origin", "trunk")
if git("rebase", "origin/trunk", check=False).returncode != 0:
git("fetch", "origin", "main")
if git("rebase", "origin/main", check=False).returncode != 0:
log.error("Rebase conflict — aborting rebase; manual intervention required.")
git("rebase", "--abort", check=False)
ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.", node)
ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.")
raise SystemExit(1)
git("submodule", "update", "--init", "--recursive")
git("push", "--force-with-lease", "origin", branch)
log.success(f"Pushed {branch}.")
if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed.", node):
if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed."):
raise SystemExit(1)
except SystemExit:
raise
except Exception as e:
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Sync failed: {e}", node)
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
raise SystemExit(1)
+10
View File
@@ -46,6 +46,16 @@ def run(ctx, args):
full = os.path.join(comp, proj)
if not os.path.isdir(full) or proj.startswith(".") or proj.startswith("@"):
continue
# opt-out: .apex-root-owned forces this project's tier data (dir + children,
# recursively) to root:root — for services that run as root and reject
# non-root-owned data (e.g. seafile-mc). Blocks noroot ownership entirely.
if os.path.isfile(os.path.join(full, ".apex-root-owned")):
log.info(f"Applying root ownership for {proj} (.apex-root-owned)")
for tp in tiers:
for d in (os.path.join(tp, proj), os.path.join(tp, "shared", proj)):
if os.path.isdir(d):
s.sudo(["chown", "-R", "root:root", d]); log.success(f" Owned {d} (root)")
continue
env_path = os.path.join(full, ".env")
uid = _env_val(env_path, "APEX_UID")
gid = _env_val(env_path, "APEX_GID")
+6
View File
@@ -34,6 +34,12 @@ def run(ctx, args):
full = os.path.join(comp, proj)
if not os.path.isdir(full) or proj.startswith(".") or proj.startswith("@"):
continue
# opt-out: a project marked .apex-root-owned runs as root and keeps its tier
# data root-owned (e.g. seafile-mc, whose entrypoint refuses non-root-owned
# data). Skip the noroot user + .env UID injection; tiers/chown forces root.
if os.path.isfile(os.path.join(full, ".apex-root-owned")):
log.info(f"{proj}: .apex-root-owned — runs as root; skipping noroot user")
continue
base = f"noroot-{_sanitize(proj)}"
cand, i = base, 1
while cand in used:
+2 -2
View File
@@ -29,7 +29,7 @@ def run(ctx, args):
ctx.sys.sudo(["find", abs_dest, "-type", "f", "-name", "*.pem", "-exec", "chmod", "600", "{}", "+"])
ctx.sys.sudo(["chown", "-R", f"{os.getuid()}:{os.getgid()}", abs_dest])
log.success("Certificates extracted.")
ctx.notify.success(TITLE, url, f"Certificates extracted for {node}", node)
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}", node)
log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}")
raise SystemExit(1)
@@ -1,30 +0,0 @@
# actions/utils/generate-happ-subscriptions.py
"""Port of utils/generate-happ-subscriptions — build + run the happ generator image."""
import os
import shutil
from engine.descriptor import Meta, Arg
METADATA = Meta(summary="Generate .psub files from a JSON config via the happ generator (docker).",
args=[Arg("input_json", "input JSON config"), Arg("output_dir", "output directory")])
IMAGE = "happ-subscription-generator"
def run(ctx, args):
log = ctx.log
src_dir = os.path.join(ctx.paths.commons, "source", "happ-subscription-generator")
inp, out = os.path.realpath(args.input_json), os.path.realpath(args.output_dir)
# GNU realpath (bash) fails unless all but the last component exist — without this
# guard a mistyped path reaches `docker run -v`, which creates it root-owned.
for label, p in (("input_json", inp), ("output_dir", out)):
parent = os.path.dirname(p)
if not os.path.isdir(parent):
log.error(f"Cannot resolve {label}: no such directory: {parent}")
raise SystemExit(1)
if not shutil.which("docker"):
log.error("docker is not installed or not in PATH.")
raise SystemExit(1)
log.info(f"Building {IMAGE}...")
ctx.sys.run(["docker", "build", "-t", IMAGE, src_dir], check=True)
log.info(f"Running generator: {inp} -> {out}")
ctx.sys.run(["docker", "run", "--rm", "-v", f"{inp}:/app/input.json:ro",
"-v", f"{out}:/app/output:rw", IMAGE, "/app/input.json", "/app/output"], check=True)
log.success("Subscription generation completed.")
+131
View File
@@ -0,0 +1,131 @@
// ============================================================================
// APEX fleet observability agent — per-node Grafana Alloy config (shared).
// Committed, env-parameterized: every node runs this SAME file; per-node values
// come from sys.env(...) (APEX_NODE_HOST, push URLs, obs basic-auth creds).
// Verified against grafana.com/docs/alloy/latest (Alloy v1.17.1).
// ============================================================================
// Model: PUSH. Metrics -> remote_write to a central Prometheus; logs -> loki.write
// to a central Loki. Push endpoints + basic-auth are per-node env
// (APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, APEX_OBS_BASIC_AUTH_USER/PASS).
// ============================================================================
// ----------------------------------------------------------------------------
// METRICS
// ----------------------------------------------------------------------------
// Host metrics (node_exporter-equivalent). Container mounts host /proc,/sys,/
// at /host/proc,/host/sys,/rootfs so the exporter reads the HOST.
prometheus.exporter.unix "node" {
procfs_path = "/host/proc"
sysfs_path = "/host/sys"
rootfs_path = "/rootfs"
}
// Container metrics (cadvisor-equivalent) via the docker socket.
prometheus.exporter.cadvisor "containers" {
docker_host = "unix:///var/run/docker.sock"
store_container_labels = true
}
// Stamp a static `node` label onto each exporter's targets.
discovery.relabel "node_metrics" {
targets = prometheus.exporter.unix.node.targets
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
// Stamp a stable `instance` (the node's public host) instead of the default,
// which is the Alloy container id/address.
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
}
}
discovery.relabel "container_metrics" {
targets = prometheus.exporter.cadvisor.containers.targets
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
}
}
prometheus.scrape "node" {
targets = discovery.relabel.node_metrics.output
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "15s"
}
prometheus.scrape "containers" {
targets = discovery.relabel.container_metrics.output
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "15s"
}
// Push metrics to the central Prometheus (needs --web.enable-remote-write-receiver).
prometheus.remote_write "default" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
}
}
// ----------------------------------------------------------------------------
// LOGS
// ----------------------------------------------------------------------------
// systemd journal (host services, ufw, sshd, crowdsec, etc.).
loki.source.journal "systemd" {
path = "/var/log/journal"
max_age = "12h"
labels = {
node = sys.env("APEX_NODE_HOST"),
job = "systemd-journal",
}
forward_to = [loki.write.default.receiver]
}
// All docker containers' stdout/stderr.
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "container_logs" {
targets = discovery.docker.containers.targets
rule {
action = "replace"
source_labels = ["__meta_docker_container_name"]
regex = "/?(.*)"
target_label = "container"
}
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.container_logs.output
relabel_rules = discovery.relabel.container_logs.rules
forward_to = [loki.write.default.receiver]
}
// Push logs to the central Loki.
loki.write "default" {
endpoint {
url = sys.env("APEX_LOKI_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
}
}
+78 -22
View File
@@ -7,10 +7,11 @@ x-environment: &shared-environment
TZ: "Europe/Moscow"
services:
# --- edge (host network) ---
# --- edge (host network; profile-gated — a node opts in via COMPOSE_PROFILES) ---
xray:
image: ghcr.io/xtls/xray-core:26.3.27
container_name: apex-xray
profiles: [xray]
volumes:
- ${APEX_TIER1}/xray/data/config.jsonc:/etc/xray/config.jsonc:ro
- ${APEX_TIER1_SHARED}/apex/letsencrypt:/etc/letsencrypt/live:ro
@@ -22,34 +23,52 @@ services:
command: ["-c", "/etc/xray/config.jsonc"]
restart: unless-stopped
user: "0:0"
labels:
# backup (resticontainer, core/all-nodes): xray config only (logs + shm are ephemeral)
- "restic.enable=true"
- "restic.backup.paths=/etc/xray/config.jsonc"
security_opt:
- no-new-privileges:true
# --- backups (host network, manual profile, one-shot) ---
restic:
image: restic/restic:0.18.1
container_name: apex-restic
# resticontainer (github.com/deytenit/resticontainer): a restic wrapper that
# discovers per-service backup intent from `restic.*` compose labels
# (restic.enable / restic.backup.paths / restic.backup.stop / restic.hooks.*).
# On `backup` it queries the docker daemon, runs each service's pre-hook (e.g.
# pg_dump), stops any container flagged restic.backup.stop, resolves the labeled
# container mounts to host paths (via the /srv mount), snapshots the union
# in a single restic run, then restarts + runs post-hooks. Every other subcommand
# (init/snapshots/forget/…) passes straight through to restic. Needs the docker
# socket (list/inspect/exec/stop/start) and /srv read-only (the fleet's data root)
# — a privileged manual one-shot, same trust surface as the old restic core.
resticontainer:
image: ghcr.io/deytenit/resticontainer:0.19.1
container_name: apex-resticontainer
profiles: [manual]
volumes:
- ${APEX_TIER3}/restic/cache:/var/restic-cache
# FIX (Task 1.11): mount the backed-up tier roots read-only so `restic backup
# /data/@tier1 /data/@tier2` actually snapshots data (was missing today).
- ${APEX_TIER_ROOT1}:/data/@tier1:ro
- ${APEX_TIER_ROOT2}:/data/@tier2:ro
- /var/run/docker.sock:/var/run/docker.sock # query + exec/stop/start
# apex tier data + checkouts live under /srv, and the @tierN bind mounts are
# ABSOLUTE symlinks into /srv (compositions/<p>/@tierN -> /srv/com-…-tierN/<p>).
# Mount /srv at the same path with RESTIC_HOSTFS=/ so restic follows those
# symlinks and records the real host paths — a bare /:/hostfs cannot, because
# the absolute symlink target /srv/… is dangling inside the container.
- /srv:/srv:ro # read the data (resolves @tierN symlinks)
environment:
<<: *shared-environment
RESTIC_HOSTFS: "/" # mount sources are already absolute (/srv/…)
RESTIC_REPOSITORY: "${APEX_RESTIC_REPOSITORY?error}"
RESTIC_PASSWORD: "${APEX_RESTIC_PASSWORD?error}"
AWS_ACCESS_KEY_ID: "${APEX_RESTIC_AWS_ACCESS_KEY_ID?error}"
AWS_SECRET_ACCESS_KEY: "${APEX_RESTIC_AWS_SECRET_ACCESS_KEY?error}"
AWS_REGION: "ru-central1"
AWS_ENDPOINT_URL: "https://storage.yandexcloud.net/"
# per-node override (icarus historically ran "max" for manual invocations;
# scheduled backups always force --compression auto on the CLI)
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-auto}"
# max compression is fleet policy (backup/run also forces --compression max on
# the CLI; needs repo format v2, which a fresh restic init creates). Per-node
# override via APEX_RESTIC_COMPRESSION only if ever needed.
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}"
RESTIC_CACHE_DIR: /var/restic-cache
network_mode: "host"
entrypoint: ["restic"]
# --- docker socket proxy ---
socket-proxy:
@@ -129,6 +148,10 @@ services:
- "traefik.http.middlewares.apex-adguard-strip-prefix.stripprefix.prefixes=/adguard"
- "traefik.http.routers.apex-adguard.service=apex-adguard"
- "traefik.http.services.apex-adguard.loadbalancer.server.port=3000"
# backup (resticontainer, core/all-nodes): dynamic routing rules only
# (letsencrypt certs are regenerable via ACME/Cloudflare DNS-01)
- "restic.enable=true"
- "restic.backup.paths=/etc/traefik/dynamic"
security_opt:
- no-new-privileges:true
@@ -142,6 +165,10 @@ services:
- ${APEX_TIER1}/adguard/conf:/opt/adguardhome/conf
environment:
<<: *shared-environment
labels:
# backup (resticontainer, core/all-nodes): adguard config only (work data regenerable)
- "restic.enable=true"
- "restic.backup.paths=/opt/adguardhome/conf"
network_mode: "service:traefik"
restart: unless-stopped
cap_add:
@@ -151,19 +178,48 @@ services:
security_opt:
- no-new-privileges:true
# --- optional: loki log aggregation (node-local, enclave) ---
loki:
image: grafana/loki:3.1.1
container_name: apex-loki
profiles: [loki]
command: ["-config.file=/etc/loki/config.yaml"]
# --- optional: observability agent (profile-gated) ---
# Grafana Alloy: one agent per node. Collects host metrics (node_exporter-
# equivalent) + container metrics (cadvisor) and PUSHES them via remote_write;
# collects systemd-journal + docker logs and PUSHES them to Loki. All targets
# are OUTBOUND to the central gfn stack (Prometheus + Loki live on icarus), so
# Alloy needs egress (direct, not enclave). Replaces the old store-only `loki`
# service (which had no feeder). Config is a committed, env-parameterized asset.
alloy:
image: grafana/alloy:v1.17.1
container_name: apex-alloy
profiles: [alloy]
user: "0:0" # root: required for host metrics, journal, and docker.sock
volumes:
- ${APEX_TIER1}/loki/config.yaml:/etc/loki/config.yaml:ro
- ${APEX_TIER2}/loki/data:/loki
- ${APEX_COMMONS}/assets/alloy/config.alloy:/etc/alloy/config.alloy:ro
- ${APEX_TIER2}/alloy/data:/var/lib/alloy
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
- /var/log/journal:/var/log/journal:ro
- /run/log/journal:/run/log/journal:ro
- /etc/machine-id:/etc/machine-id:ro
environment:
<<: *shared-environment
APEX_NODE_HOST: "${APEX_NODE_HOST?error}" # node + instance metric/log labels
# push targets + machine auth (per-node scalars in apex.env / secret .env)
APEX_LOKI_PUSH_URL: "${APEX_LOKI_PUSH_URL?error}"
APEX_PROM_PUSH_URL: "${APEX_PROM_PUSH_URL?error}"
APEX_OBS_BASIC_AUTH_USER: "${APEX_OBS_BASIC_AUTH_USER?error}"
APEX_OBS_BASIC_AUTH_PASS: "${APEX_OBS_BASIC_AUTH_PASS?error}"
command:
- run
- --server.http.listen-addr=0.0.0.0:12345
- --storage.path=/var/lib/alloy
- /etc/alloy/config.alloy
networks:
enclave: {}
direct: {}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
user: "${APEX_UID?error}:${APEX_GID?error}"
security_opt:
- no-new-privileges:true
networks:
direct:
+3 -4
View File
@@ -81,7 +81,7 @@ class Context:
self.ufw = Ufw(self.log, self.sys, self.host)
self.crowdsec = Crowdsec(self.log, self.sys, self.tpl, self.host)
self.systemd = Systemd(self.log, self.sys, self.tpl, self.host)
self.notify = Notify(self.log, name)
self.notify = Notify(self.log, node.host if node else "global")
self._shadowed = shadowed
self._commons = None
@@ -98,10 +98,9 @@ class Context:
v["APEX_REPO_ROOT"] = self.paths.repo_root
if self.node:
v.update({
"APEX_NODE": self.node.name,
"APEX_CLUSTER": self.node.cluster,
"APEX_SUBNET": self.node.subnet,
"APEX_NODE_HOST": self.node.host,
"APEX_NODE_FQDN": self.node.fqdn,
"APEX_SUBNET": self.node.subnet,
"APEX_TIER1": self.paths.tier1, "APEX_TIER2": self.paths.tier2,
"APEX_TIER3": self.paths.tier3,
"APEX_TIER1_SHARED": self.paths.stier1,
+24 -36
View File
@@ -1,29 +1,29 @@
# engine/identity.py
"""Node identity from host FQDN + node.env (replaces bash root_require_node).
"""Node identity — fully explicit, from node.env. The framework assumes no domain
and no clustering: a node declares its real FQDN and its public host name, and the
short `name` is just the first label. The OS hostname is only a fallback for the FQDN.
Decision B: the FQDN <node>.a<x>.apex.ermnvldmr.com keeps the cluster; we parse
node+cluster from it. node.env owns APEX_SUBNET and mirrors APEX_CLUSTER; hostname
wins on drift. Falls back to the repo directory name when the FQDN is not yet
apex-shaped (mid-cutover).
node.env keys:
APEX_NODE_FQDN real FQDN (traefik Host() routing). default: OS hostname
APEX_NODE_HOST public host name (backup --host, obs/notify default: APEX_NODE_FQDN
`instance`); often the FQDN minus a cluster
segment, but that's the node's choice.
APEX_SUBNET the node's /24 for the `direct` network. required
"""
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass
from typing import Dict, List, Tuple
_FQDN = re.compile(r"^(?P<node>[^.]+)\.(?P<cluster>a\d+)\.apex\.ermnvldmr\.com$")
_DIRNAME = re.compile(r"^(?P<node>[^.]+)\.apex\.ermnvldmr\.com")
@dataclass
class Node:
name: str
cluster: str
subnet: str
fqdn: str
name: str # short label (first segment of the host), e.g. "icarus" — logs, branch, messages
subnet: str # the node's direct-network /24
fqdn: str # real FQDN — traefik Host() routing
host: str # public host name — backup --host, obs `instance`, notify instance
def read_env(path: str) -> Dict[str, str]:
@@ -41,36 +41,24 @@ def read_env(path: str) -> Dict[str, str]:
return out
def resolve(fqdn: str, repo_root: str) -> Tuple[Node, List[str]]:
def resolve(hostname: str, repo_root: str) -> Tuple[Node, List[str]]:
"""Return (Node, warnings). Exits 66 (EX_NOINPUT) if APEX_SUBNET is missing."""
warns: List[str] = []
env = read_env(os.path.join(repo_root, "node.env"))
m = _FQDN.match(fqdn or "")
if m:
name, cluster = m.group("node"), m.group("cluster")
env_cluster = env.get("APEX_CLUSTER")
if env_cluster and env_cluster != cluster:
warns.append(
f"cluster drift: hostname says {cluster!r}, node.env says "
f"{env_cluster!r}; hostname wins."
)
else:
# Fallback: derive node from repo dir name; cluster from node.env.
warns.append(
f"FQDN {fqdn!r} is not apex-shaped; falling back to repo dir + node.env."
)
base = os.path.basename(os.path.realpath(repo_root))
dm = _DIRNAME.match(base)
name = dm.group("node") if dm else base
cluster = env.get("APEX_CLUSTER", "")
if not cluster:
sys.stderr.write("identity: no cluster in FQDN or node.env\n")
raise SystemExit(66)
fqdn = env.get("APEX_NODE_FQDN", "") or (hostname or "").strip()
if not fqdn:
sys.stderr.write("identity: APEX_NODE_FQDN missing from node.env and no hostname\n")
raise SystemExit(66)
if not env.get("APEX_NODE_FQDN"):
warns.append(f"APEX_NODE_FQDN not set in node.env; using hostname {fqdn!r}.")
host = env.get("APEX_NODE_HOST", "") or fqdn # node's public name; defaults to the FQDN
name = host.split(".")[0] # short label for logs/branch/messages
subnet = env.get("APEX_SUBNET", "")
if not subnet:
sys.stderr.write("identity: APEX_SUBNET missing from node.env\n")
raise SystemExit(66)
return Node(name=name, cluster=cluster, subnet=subnet, fqdn=fqdn), warns
return Node(name=name, subnet=subnet, fqdn=fqdn, host=host), warns
+4 -5
View File
@@ -1,9 +1,8 @@
# engine/lib/notify.py
"""ctx.notify — Telegram notifications (port of root_core_telegram*).
"""ctx.notify — Telegram notifications.
instance renamed com.ermnvldmr.root.<node> -> com.ermnvldmr.apex.<node>. POSTs
application/x-www-form-urlencoded via urllib (stdlib) — functionally identical to
the bash curl multipart for Telegram sendMessage.
The alert `instance` is the node's public host (APEX_NODE_HOST), passed in from the
context. POSTs application/x-www-form-urlencoded via urllib (stdlib).
"""
from __future__ import annotations
import html
@@ -20,7 +19,7 @@ def build_text(title: str, message: str, node: str, level: str, ts: str) -> str:
severity = "critical"
elif level == "WARN":
severity = "warning"
instance = f"com.ermnvldmr.apex.{node}"
instance = node
msg = message if len(message) <= 200 else message[:200] + "..."
msg = html.escape(msg)
safe_title = html.escape(title)
@@ -1,7 +0,0 @@
FROM python:3.11-alpine
WORKDIR /app
COPY generator.py .
ENTRYPOINT ["python", "/app/generator.py"]
@@ -1,35 +0,0 @@
# HAPP Subscription Generator
**Disclaimer:** This tool is intended exclusively for **educational purposes** and **private networking** research. It is designed to demonstrate how to programmatically generate and manage subscription configurations for distributed network components.
This tool generates `.psub` subscription files from a JSON configuration. It supports global routing via `happ://` links and multiple VLESS connection strings.
## Features
- **Global Routing**: Encodes your `routing` object into the `happ://routing/onadd/` link.
- **Templates**: Define common Xray-core settings once and reuse them across users.
- **Deep Merge**: User-specific `xray` overrides are merged with templates.
- **Auto Remarks**: Generates readable titles based on country, protocol, and node names.
- **ISO Lookup**: Automatically converts country codes (e.g., `US`) to names and emojis.
## Usage
The tool is designed to be run via the operator script:
```bash
./.operator/scripts/ops/generate-happ-subscriptions <input_json> <output_dir>
```
### JSON Format Specification
The configuration file consists of three main top-level objects:
1. **`routing`**: Global routing settings (DNS, direct/proxy rules, etc.).
2. **`templates`**: Named connection templates containing shared protocol and stream settings.
3. **`users`**: List of users, each with a unique subscription key (`psub`) and a set of configurations derived from templates.
#### Example Reference
See [example.json](./example.json) for a comprehensive template of the configuration format.
## Implementation Details
- **Logic**: Written in Python 3.11 (standard library only).
- **Isolation**: Runs inside a Docker container (`python:3.11-alpine`).
@@ -1,141 +0,0 @@
{
"routing": {
"name": "enterprise-secure-access",
"GlobalProxy": "false",
"RouteOrder": "block-proxy-direct",
"RemoteDNSType": "DoU",
"RemoteDNSDomain": "internal.corp",
"RemoteDNSIP": "10.50.10.10",
"DomesticDNSType": "DoH",
"DomesticDNSDomain": "https://dns.example.com/query",
"DomesticDNSIP": "1.1.1.1",
"Geoipurl": "https://example.com/data/geoip.dat",
"Geositeurl": "https://example.com/data/geosite.dat",
"LastUpdated": "2026-04-14",
"DnsHosts": {
"dns.example.com": "1.1.1.1",
"gateway.internal.corp": "10.50.10.1"
},
"DirectSites": [
"geosite:google",
"geosite:microsoft",
"geosite:private",
"domain:internal.corp",
"regexp:.*\\.local$"
],
"DirectIp": [
"geoip:private",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16"
],
"ProxyIp": [
"10.50.0.0/16",
"10.60.0.0/16",
"172.20.0.0/14"
],
"DomainStrategy": "IPIfNonMatch",
"FakeDNS": "false",
"UseChunkFiles": "true"
},
"templates": {
"corp-vpn-v1": {
"country": "US",
"nodes": ["hq-gateway-01"],
"xray": {
"protocol": "vless",
"settings": {
"address": "vpn-hq.example.net",
"port": 443,
"encryption": "none",
"flow": "xtls-rprx-vision"
},
"streamSettings": {
"network": "raw",
"security": "tls",
"tlsSettings": {
"serverName": "vpn-hq.example.net",
"allowInsecure": false,
"fingerprint": "chrome",
"alpn": ["h2", "http/1.1"]
}
}
}
},
"corp-vpn-v2": {
"country": "UK",
"nodes": ["branch-office-uk"],
"xray": {
"protocol": "vless",
"settings": {
"address": "vpn-uk.example.net",
"port": 443,
"encryption": "none",
"flow": "xtls-rprx-vision"
},
"streamSettings": {
"network": "xhttp",
"security": "tls",
"tlsSettings": {
"serverName": "vpn-uk.example.net",
"fingerprint": "chrome",
"alpn": ["h3", "h2"]
},
"xhttpSettings": {
"mode": "stream-one",
"path": "/internal-access-bridge/"
}
}
}
},
"secure-enclave": {
"country": "DE",
"nodes": ["secure-zone-alpha"],
"xray": {
"protocol": "vless",
"settings": {
"address": "secure.example.net",
"port": 443,
"encryption": "none",
"flow": "xtls-rprx-vision"
},
"streamSettings": {
"network": "raw",
"security": "reality",
"realitySettings": {
"dest": "internal-app.example.net",
"fingerprint": "chrome",
"serverNames": [
"internal-app.example.net"
],
"publicKey": "base64-encoded-public-key-placeholder"
}
},
"mux": {
"enabled": true,
"concurrency": 8
}
}
}
},
"users": [
{
"name": "employee-01",
"psub": "sub-key-dev-01",
"configs": [
{ "template": "corp-vpn-v1", "xray": { "settings": { "id": "uuid-placeholder-01" } } },
{ "template": "corp-vpn-v2", "xray": { "settings": { "id": "uuid-placeholder-01" } } },
{ "template": "secure-enclave", "xray": { "settings": { "id": "uuid-placeholder-01" }, "streamSettings": { "realitySettings": { "shortId": "short-id-01" } } } }
]
},
{
"name": "employee-02",
"psub": "sub-key-admin-02",
"configs": [
{ "template": "corp-vpn-v1", "xray": { "settings": { "id": "uuid-placeholder-02" } } },
{ "template": "corp-vpn-v2", "xray": { "settings": { "id": "uuid-placeholder-02" } } },
{ "template": "secure-enclave", "xray": { "settings": { "id": "uuid-placeholder-02" }, "streamSettings": { "realitySettings": { "shortId": "short-id-02" } } } }
]
}
]
}
@@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""
HAPP subscription generator.
Produces one .psub file per user. Each file contains:
line 1 : happ://routing/onadd/<base64> (routing profile, unchanged behaviour)
line N : a share link for every config the user has
Design
------
The config is written 1:1 against the HAPP / Xray share-link format. A template
is just the literal pieces of a URI:
scheme -> vless:// , hy2:// , trojan:// , ss:// , socks:// , vmess://
host -> the @host part
port -> the :port part
id -> the userinfo before @ (uuid / password / auth)
params -> the ?a=b&c=d query, keys are the REAL link keys
(type, security, sni, fp, alpn, flow, encryption,
pbk, sid, spx, mode, path, host, serviceName,
headerType, mux, obfs, ... whatever the link supports)
Nothing is translated or guessed. Whatever you put in `params` is what goes in
the link, url-encoded. This keeps the generator dumb and the config honest.
Per-user `configs` entries deep-merge onto the named template, so a user only
supplies the bits that differ (their `id`, their `params.sid`, etc.).
The `#remark` is built from template metadata (country / nodes) + the user name.
"""
import json
import base64
import sys
import os
import copy
from urllib.parse import urlencode, quote
# ISO 3166-1 alpha-2 -> (flag, display name). Extend as needed.
COUNTRY_MAP = {
"LV": ("\U0001F1F1\U0001F1FB", "Latvia"),
"RU": ("\U0001F1F7\U0001F1FA", "Russia"),
"KZ": ("\U0001F1F0\U0001F1FF", "Kazakhstan"),
"BY": ("\U0001F1E7\U0001F1FE", "Belarus"),
}
def deep_merge(base, override):
"""Recursively merge `override` into a copy of `base`. Lists/scalars replace."""
out = copy.deepcopy(base)
for key, value in override.items():
if isinstance(out.get(key), dict) and isinstance(value, dict):
out[key] = deep_merge(out[key], value)
else:
out[key] = copy.deepcopy(value)
return out
def routing_link(routing):
"""happ://routing/onadd/<base64(json)>"""
payload = json.dumps(routing, separators=(",", ":")).encode()
return "happ://routing/onadd/" + base64.b64encode(payload).decode()
def build_remark(node, user_name):
code = node.get("country", "")
flag, name = COUNTRY_MAP.get(code, ("\U0001F310", code or "??"))
transport = node.get("params", {}).get("type", "tcp")
chain = " > ".join(node.get("nodes", ["unknown"]))
return f"{flag} {name} [{transport}] | {chain} | {user_name}"
def build_link(node, user_name):
"""Assemble one share link from a fully-merged node dict."""
scheme = node["scheme"]
host = node["host"]
port = node["port"]
userinfo = node.get("id", "")
# Drop empty params so the query stays clean; preserve insertion order.
params = {k: v for k, v in node.get("params", {}).items()
if v is not None and v != ""}
# Lists (e.g. alpn) -> comma-joined; everything else stringified.
flat = {}
for k, v in params.items():
flat[k] = ",".join(map(str, v)) if isinstance(v, list) else str(v)
query = urlencode(flat, safe="/:")
remark = quote(build_remark(node, user_name))
base = f"{scheme}://{userinfo}@{host}:{port}"
return f"{base}?{query}#{remark}" if query else f"{base}#{remark}"
def main():
if len(sys.argv) < 3:
print("Usage: generator.py <input_json> <output_dir>")
sys.exit(1)
input_path, output_dir = sys.argv[1], sys.argv[2]
with open(input_path, "r", encoding="utf-8") as f:
data = json.load(f)
routing = routing_link(data.get("routing", {}))
templates = data.get("templates", {})
users = data.get("users", [])
os.makedirs(output_dir, exist_ok=True)
for user in users:
name = user["name"]
out_path = os.path.join(output_dir, user.get("psub", name))
lines = [routing]
for entry in user.get("configs", []):
tmpl = entry.get("template")
node = deep_merge(templates.get(tmpl, {}), entry) if tmpl else entry
lines.append(build_link(node, name))
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
print(f"Generated: {out_path}")
if __name__ == "__main__":
main()
+20 -23
View File
@@ -3,40 +3,37 @@ import os, tempfile, unittest
from engine.identity import resolve, Node
class TestIdentity(unittest.TestCase):
def _envfile(self, text, dirname=None):
def _envfile(self, text):
d = tempfile.mkdtemp()
if dirname:
d = os.path.join(d, dirname)
os.makedirs(d)
with open(os.path.join(d, "node.env"), "w") as f:
f.write(text)
return d
def test_fqdn_is_source_of_truth(self):
repo = self._envfile("APEX_CLUSTER=a1\nAPEX_SUBNET=198.18.11.0/24\n")
n, warns = resolve("daedalus.a1.apex.ermnvldmr.com", repo)
self.assertEqual((n.name, n.cluster, n.subnet), ("daedalus", "a1", "198.18.11.0/24"))
def test_explicit_host_and_fqdn(self):
repo = self._envfile("APEX_NODE_FQDN=icarus.a1.example.com\n"
"APEX_NODE_HOST=icarus.example.com\n"
"APEX_SUBNET=198.18.17.0/24\n")
n, warns = resolve("build-laptop", repo) # hostname ignored when node.env has FQDN
self.assertEqual((n.name, n.host, n.fqdn, n.subnet),
("icarus", "icarus.example.com", "icarus.a1.example.com", "198.18.17.0/24"))
self.assertEqual(warns, [])
def test_cluster_drift_warns_hostname_wins(self):
repo = self._envfile("APEX_CLUSTER=a9\nAPEX_SUBNET=198.18.11.0/24\n")
n, warns = resolve("daedalus.a1.apex.ermnvldmr.com", repo)
self.assertEqual(n.cluster, "a1") # hostname wins
self.assertTrue(any("drift" in w.lower() for w in warns))
def test_host_defaults_to_fqdn(self):
repo = self._envfile("APEX_NODE_FQDN=n1.example.com\nAPEX_SUBNET=10.0.0.0/24\n")
n, warns = resolve("ignored", repo)
self.assertEqual((n.name, n.host, n.fqdn), ("n1", "n1.example.com", "n1.example.com"))
self.assertEqual(warns, [])
def test_fallback_when_fqdn_not_apex_shaped(self):
# repo dir basename carries the node name in fallback (real host layout:
# $HOME/morpheus.apex.ermnvldmr.com)
repo = self._envfile("APEX_CLUSTER=a2\nAPEX_SUBNET=198.18.13.0/24\n",
dirname="morpheus.apex.ermnvldmr.com")
n, warns = resolve("build-laptop", repo)
self.assertEqual((n.name, n.cluster, n.subnet), ("morpheus", "a2", "198.18.13.0/24"))
self.assertTrue(any("fqdn" in w.lower() for w in warns))
def test_fqdn_falls_back_to_hostname(self):
repo = self._envfile("APEX_SUBNET=10.0.0.0/24\n")
n, warns = resolve("box.local", repo)
self.assertEqual((n.name, n.host, n.fqdn), ("box", "box.local", "box.local"))
self.assertTrue(any("APEX_NODE_FQDN" in w for w in warns))
def test_missing_subnet_is_error(self):
repo = self._envfile("APEX_CLUSTER=a1\n")
repo = self._envfile("APEX_NODE_FQDN=n1.example.com\n")
with self.assertRaises(SystemExit):
resolve("daedalus.a1.apex.ermnvldmr.com", repo)
resolve("n1.example.com", repo)
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -3,10 +3,10 @@ import unittest
from engine.lib.notify import build_text
class TestNotify(unittest.TestCase):
def test_instance_uses_apex_prefix(self):
t = build_text("Title", "msg", "daedalus", "INFO", "2026-07-04 00:00:00 MSK")
self.assertIn("com.ermnvldmr.apex.daedalus", t)
self.assertIn("#instance_com_ermnvldmr_apex_daedalus", t)
def test_instance_is_the_host(self):
t = build_text("Title", "msg", "icarus.example.com", "INFO", "2026-07-04 00:00:00 MSK")
self.assertIn("icarus.example.com", t)
self.assertIn("#instance_icarus_example_com", t) # dots -> underscores in the hashtag
def test_html_escaped_and_truncated(self):
t = build_text("A & B", "<script>" + "x" * 300, "d", "ERROR", "T")