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.
This commit is contained in:
1 parent
65fef9a6b0
commit
99ff1f1433
5 files changed
+248
No files matched your search
@@ -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.")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY generator.py .
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "/app/generator.py"]
|
||||||
@@ -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.
|
||||||
@@ -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/<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 = {
|
||||||
|
"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/<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 = ("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 <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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user