diff --git a/PARITY.md b/PARITY.md index 2c5b3f7..167ea23 100644 --- a/PARITY.md +++ b/PARITY.md @@ -19,7 +19,7 @@ action to its bash source and the behaviors that must match. | tiers/useradd | actions/tiers/useradd | noroot- 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 | @@ -48,8 +48,10 @@ 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:///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 diff --git a/README.md b/README.md index d869b5d..6f363db 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,11 @@ 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 `` argument. Identity comes from the host FQDN (`.a.apex.ermnvldmr.com`) and `node.env`. diff --git a/actions/utils/generate-happ-subscriptions.py b/actions/utils/generate-happ-subscriptions.py deleted file mode 100644 index edbf156..0000000 --- a/actions/utils/generate-happ-subscriptions.py +++ /dev/null @@ -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.") diff --git a/compositions/apex/docker-compose.yml b/compositions/apex/docker-compose.yml index d37e9dc..94ede5c 100644 --- a/compositions/apex/docker-compose.yml +++ b/compositions/apex/docker-compose.yml @@ -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 @@ -164,6 +165,14 @@ services: enclave: {} restart: unless-stopped user: "${APEX_UID?error}:${APEX_GID?error}" + labels: + # /loki/* is loki's native API prefix — no strip needed. basicauth users come + # from the node's uncommitted .env (only needed when the loki profile is on). + - "traefik.enable=true" + - "traefik.http.routers.apex-loki.rule=Host(`${APEX_NODE_FQDN}`) && PathPrefix(`/loki`)" + - "traefik.http.routers.apex-loki.middlewares=apex-loki-auth" + - "traefik.http.middlewares.apex-loki-auth.basicauth.users=${APEX_LOKI_BASIC_AUTH}" + - "traefik.http.services.apex-loki.loadbalancer.server.port=3100" networks: direct: diff --git a/source/happ-subscription-generator/Dockerfile b/source/happ-subscription-generator/Dockerfile deleted file mode 100644 index 0fa9e5d..0000000 --- a/source/happ-subscription-generator/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM python:3.11-alpine - -WORKDIR /app - -COPY generator.py . - -ENTRYPOINT ["python", "/app/generator.py"] diff --git a/source/happ-subscription-generator/README.md b/source/happ-subscription-generator/README.md deleted file mode 100644 index 33ab88b..0000000 --- a/source/happ-subscription-generator/README.md +++ /dev/null @@ -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 -``` - -### 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`). diff --git a/source/happ-subscription-generator/example.json b/source/happ-subscription-generator/example.json deleted file mode 100644 index e858ee7..0000000 --- a/source/happ-subscription-generator/example.json +++ /dev/null @@ -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" } } } } - ] - } - ] -} diff --git a/source/happ-subscription-generator/generator.py b/source/happ-subscription-generator/generator.py deleted file mode 100644 index c6ae0f7..0000000 --- a/source/happ-subscription-generator/generator.py +++ /dev/null @@ -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/ (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/""" - 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 ") - 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()