From 99ff1f1433fa787710685398332c656043d4ef8a Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sun, 11 Oct 2026 01:49:44 +0300 Subject: [PATCH] fix: share Happ generation and correct protocol labels Move the reusable generator into commons, add Netherlands display metadata, and label Hysteria links by protocol without changing URI parameters. Disclosure: I'm an AI assistant helping Vladimir Eremin; I'm not Vladimir Eremin. --- actions/utils/generate-happ-subscriptions.py | 29 ++++ assets/happ-subscription-generator/Dockerfile | 7 + assets/happ-subscription-generator/README.md | 20 +++ .../happ-subscription-generator/generator.py | 130 ++++++++++++++++++ tests/test_happ_subscriptions.py | 62 +++++++++ 5 files changed, 248 insertions(+) create mode 100644 actions/utils/generate-happ-subscriptions.py create mode 100644 assets/happ-subscription-generator/Dockerfile create mode 100644 assets/happ-subscription-generator/README.md create mode 100644 assets/happ-subscription-generator/generator.py create mode 100644 tests/test_happ_subscriptions.py diff --git a/actions/utils/generate-happ-subscriptions.py b/actions/utils/generate-happ-subscriptions.py new file mode 100644 index 0000000..33dfcd0 --- /dev/null +++ b/actions/utils/generate-happ-subscriptions.py @@ -0,0 +1,29 @@ +"""Build and run the shared Happ subscription generator.""" +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, "assets", "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/assets/happ-subscription-generator/Dockerfile b/assets/happ-subscription-generator/Dockerfile new file mode 100644 index 0000000..0fa9e5d --- /dev/null +++ b/assets/happ-subscription-generator/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-alpine + +WORKDIR /app + +COPY generator.py . + +ENTRYPOINT ["python", "/app/generator.py"] diff --git a/assets/happ-subscription-generator/README.md b/assets/happ-subscription-generator/README.md new file mode 100644 index 0000000..bcd1af6 --- /dev/null +++ b/assets/happ-subscription-generator/README.md @@ -0,0 +1,20 @@ +# Happ subscriptions + +Run the shared action from a node checkout: + +```sh +./commons/apex utils/generate-happ-subscriptions /private/subscriptions.json /private/output +``` + +The action builds the bundled Docker image and generates one file per user. Keep +input credentials and output subscription links outside Git. Regenerate into a +private staging directory and verify the files before replacing served outputs. + +The input contains `routing`, `templates` and `users`. Templates provide `scheme`, +`host`, `port`, URI `params`, and display metadata (`country`, `nodes`). Each user's +`configs` entries select a `template` and merge user-specific values such as `id` +and `params`; `psub` selects the output filename. URI parameters are preserved. + +Labels use the country, transport, node chain and user name. `NL` renders as +`🇳🇱 Netherlands`. Schemes `hysteria2` and `hy2` display `[hysteria]`; other schemes +use `params.type`, defaulting to `tcp`. This changes display text only. diff --git a/assets/happ-subscription-generator/generator.py b/assets/happ-subscription-generator/generator.py new file mode 100644 index 0000000..cef41b9 --- /dev/null +++ b/assets/happ-subscription-generator/generator.py @@ -0,0 +1,130 @@ +#!/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 = { + "NL": ("\U0001F1F3\U0001F1F1", "Netherlands"), + "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 = ("hysteria" if node.get("scheme") in ("hysteria2", "hy2") + else 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() diff --git a/tests/test_happ_subscriptions.py b/tests/test_happ_subscriptions.py new file mode 100644 index 0000000..9b5d79d --- /dev/null +++ b/tests/test_happ_subscriptions.py @@ -0,0 +1,62 @@ +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest +from urllib.parse import parse_qs, unquote, urlsplit + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +GENERATOR = ROOT / 'assets/happ-subscription-generator/generator.py' + + +class TestHappSubscriptions(unittest.TestCase): + def generate(self, scheme, params, country='NL'): + data = { + 'routing': {'name': 'test-routing'}, + 'templates': {'edge': { + 'country': country, 'nodes': ['metis'], 'scheme': scheme, + 'host': 'edge.example.com', 'port': 443, 'params': params, + }}, + 'users': [{'name': 'test-user', 'psub': 'test.psub', 'configs': [ + {'template': 'edge', 'id': 'test-credential', 'params': {'sni': 'tls.example.com'}}, + ]}], + } + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / 'input.json' + source.write_text(json.dumps(data)) + result = subprocess.run( + [sys.executable, str(GENERATOR), str(source), tmp], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + lines = (pathlib.Path(tmp) / 'test.psub').read_text().splitlines() + self.assertEqual(len(lines), 2) + return urlsplit(lines[1]) + + def test_hysteria_names_follow_protocol_without_changing_link_parameters(self): + for scheme in ('hysteria2', 'hy2'): + for params in ({}, {'type': 'tcp'}): + with self.subTest(scheme=scheme, params=params): + link = self.generate(scheme, params) + self.assertEqual(unquote(link.fragment), + '🇳🇱 Netherlands [hysteria] | metis | test-user') + self.assertEqual(link.scheme, scheme) + self.assertEqual(link.netloc, 'test-credential@edge.example.com:443') + self.assertEqual(parse_qs(link.query), + {**{k: [v] for k, v in params.items()}, 'sni': ['tls.example.com']}) + + def test_vless_keeps_transport_label_and_user_overrides(self): + link = self.generate('vless', {'type': 'xhttp', 'sni': 'old.example.com'}) + self.assertEqual(unquote(link.fragment), + '🇳🇱 Netherlands [xhttp] | metis | test-user') + self.assertEqual(parse_qs(link.query), {'type': ['xhttp'], 'sni': ['tls.example.com']}) + + def test_existing_country_and_tcp_label_are_preserved(self): + link = self.generate('vless', {'type': 'tcp'}, country='LV') + self.assertEqual(unquote(link.fragment), '🇱🇻 Latvia [tcp] | metis | test-user') + + +if __name__ == '__main__': + unittest.main()