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