tmp: default protocol ss

This commit is contained in:
RRRosmontis
2026-09-10 01:53:49 +08:00
parent 20d2532e8f
commit ec43920207
+356
View File
@@ -0,0 +1,356 @@
# -*- coding: utf-8 -*-
"""
RRPP 订阅后端(api.rrrosmontis.icu)— ROSMANKA ID SSO 国家分层版
URL 形式:
/v1/rrpp/subscription?method=<clash|v2ray|rrpp_client>&user=<base64url邮箱>&password=<base64url密码>[&prefer=<vless|ss|http>]
/v1/rrpp/nodes_update?user=...&password=... (旧端点,等价于 method=rrpp_client)
数据结构(nodes.json,顶层为国家列表):
[
{
"country": "JP",
"available_level": [1, 2, 3],
"provider": "Azure",
"traffic": "15G",
"domain": "iam.aeolianchi.me",
"methods": {
"http": { "port": ..., "username": ..., "password": ... },
"vless": { "port": ..., "uuid": ..., "flow": ..., "sni": ..., "public_key": ..., "short_id": ... },
"ss": { "port": ..., "method": ..., "password": ... }
}
},
...
]
协议选择(clash / v2ray):
prefer 参数指定优先协议(vless / ss / http),缺省默认 vless(最好用)。
若该国家无 prefer 协议,按 vless → ss → http 回退。
method 行为:
rrpp_client → 仅用 methods.http,返回旧格式节点 JSON(domain/port/country/username/password/remark),
remark = "<provider> <traffic>"(IPv6 加 "(IPv6)"),保证自建客户端兼容性。
clash → 按协议生成 Clash YAML,备注 "R.R.P.P. - <国家代码>"(IPv6 加 "(IPv6)")
v2ray → 按协议生成 base64 节点串(vless:// / ss:// / http://),同备注
tier 过滤:国家对象 available_level 包含用户 tier 才可见。
tier=0 → 403;未知 method → 404;nodes.json 缺失/损坏 → 503。
运行:python app.py(监听 127.0.0.1:5010,nginx 反向代理)
服务:由 nssm 注册为 Windows 服务 RRPPSubBackend,开机自启
"""
import base64
import json
import urllib.error
import urllib.parse
import urllib.request
from flask import Flask, request, Response
app = Flask(__name__)
SSO_API = 'http://127.0.0.1:4550/v1/sso'
SSO_SECRETS_PATH = 'C:/BtSoft/nginx/others/backEnd/sso-rrrosmontis-icu/secrets.json'
NODES_JSON_PATH = 'C:/BtSoft/nginx/others/htmlResources/private/RRPP/nodes.json'
# 协议优先级(缺省时按此顺序回退)
PROTOCOL_PRIORITY = ['ss', 'http', 'vless']
def _service_token():
"""读取本机 SSO 的服务共享密钥(单一密钥源,不落代码)。"""
try:
with open(SSO_SECRETS_PATH, 'r', encoding='utf-8') as f:
return json.load(f).get('service_tokens', {}).get('rrpp', '')
except Exception:
return ''
def b64url_decode(s):
"""URL-safe base64 解码(容忍无 padding 与标准 base64)。"""
if not s:
return None
pad = '=' * (-len(s) % 4)
try:
return base64.urlsafe_b64decode((s + pad).encode('ascii')).decode('utf-8')
except Exception:
return None
def fetch_user_tier(email, password, client_ip=''):
"""SSO 校验邮箱+密码并取 rrpp tier。返回 (ok, err, tier)。
client_ip 透传给 SSO(X-Real-IP),使 SSO 限流按真实客户端 IP 计。"""
try:
req = urllib.request.Request(
SSO_API + '/service/rrpp/nodes',
data=json.dumps({'email': email, 'password': password}).encode('utf-8'),
headers={'Content-Type': 'application/json',
'X-SSO-Service-Token': _service_token(),
'X-Real-IP': client_ip})
with urllib.request.urlopen(req, timeout=10) as resp:
d = json.loads(resp.read())
return True, '', d.get('tier', 0)
except urllib.error.HTTPError as e:
try:
msg = json.loads(e.read()).get('error', '认证失败')
except Exception:
msg = '认证失败'
return False, msg, 0
except Exception:
return False, 'SSO 服务不可达', 0
def load_countries():
"""读 nodes.json(国家分层)。返回国家列表,失败抛异常。"""
with open(NODES_JSON_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
def filter_countries_by_tier(countries, tier):
"""保留 available_level 包含该 tier 的国家。"""
return [c for c in countries if tier in c.get('available_level', [])]
def pick_method(country, prefer=None):
"""根据国家 methods 选择协议。返回 (protocol, method_config)。
prefer 指定则优先;否则按 PROTOCOL_PRIORITY 回退。"""
methods = country.get('methods', {})
if prefer and prefer in methods:
return prefer, methods[prefer]
for proto in PROTOCOL_PRIORITY:
if proto in methods:
return proto, methods[proto]
return None, None
def country_remark(country):
"""clash/v2ray 备注:R.R.P.P. - <国家代码>(IPv6 加 "(IPv6)")。"""
remark = f"R.R.P.P. - {country['country']}"
if country.get('ipv6_only'):
remark += ' (IPv6)'
return remark
# ---------- 各协议 → v2ray 分享链接 ----------
def vless_url(domain, port, cfg, remark):
params = {
'security': 'reality',
'encryption': 'none',
'pbk': cfg['public_key'],
'headerType': 'none',
'fp': 'chrome',
'type': 'tcp',
'flow': cfg.get('flow', 'xtls-rprx-vision'),
'sni': cfg['sni'],
'sid': cfg['short_id'],
}
qs = '&'.join(f'{k}={v}' for k, v in params.items())
return f"vless://{cfg['uuid']}@{domain}:{port}?{qs}#{urllib.parse.quote(remark, safe='')}"
def ss_url(domain, port, cfg, remark):
userinfo = base64.urlsafe_b64encode(
f"{cfg['method']}:{cfg['password']}".encode()).decode().rstrip('=')
return f"ss://{userinfo}@{domain}:{port}#{urllib.parse.quote(remark, safe='')}"
def http_url(domain, port, cfg, remark):
up = base64.urlsafe_b64encode(
f"{cfg['username']}:{cfg['password']}".encode()).decode().rstrip('=')
return f"http://{up}@{domain}:{port}#{urllib.parse.quote(remark, safe='')}"
URL_BUILDERS = {
'vless': vless_url,
'ss': ss_url,
'http': http_url,
}
def countries_to_v2ray_sub(countries, prefer=None):
"""生成 v2ray base64 订阅串。"""
lines = []
for c in countries:
proto, cfg = pick_method(c, prefer)
if proto and proto in URL_BUILDERS:
lines.append(URL_BUILDERS[proto](
c['domain'], cfg['port'], cfg, country_remark(c)))
return base64.b64encode('\n'.join(lines).encode('utf-8')).decode('ascii')
# ---------- 各协议 → Clash YAML ----------
def clash_proxy(domain, port, cfg, proto, name):
"""节点 → Clash proxies 元素。"""
base = {'name': name, 'server': domain, 'port': port}
if proto == 'vless':
base.update({
'type': 'vless',
'uuid': cfg['uuid'],
'network': 'tcp',
'tls': True,
'udp': True,
'flow': cfg.get('flow', 'xtls-rprx-vision'),
'servername': cfg['sni'],
'reality-opts': {
'public-key': cfg['public_key'],
'short-id': cfg['short_id'],
},
'client-fingerprint': 'chrome',
})
elif proto == 'ss':
base.update({'type': 'ss', 'cipher': cfg['method'], 'password': cfg['password']})
elif proto == 'http':
base.update({'type': 'http', 'username': cfg['username'], 'password': cfg['password']})
return base
def countries_to_clash_yaml(countries, prefer=None):
"""生成最小可用 Clash YAML。"""
proxies = []
for c in countries:
proto, cfg = pick_method(c, prefer)
if proto:
proxies.append(clash_proxy(
c['domain'], cfg['port'], cfg, proto, country_remark(c)))
names = [p['name'] for p in proxies]
doc = {
'proxies': proxies,
'proxy-groups': [{
'name': 'RRPP',
'type': 'select',
'proxies': names,
}],
'rules': ['MATCH,RRPP'],
}
return _dump_simple_yaml(doc)
def _dump_simple_yaml(obj, indent=0):
"""极简 YAML 序列化(足够 proxies/groups/rules 结构)。"""
sp = ' ' * indent
lines = []
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, (dict, list)):
lines.append(f'{sp}{k}:')
lines.append(_dump_simple_yaml(v, indent + 1))
else:
lines.append(f'{sp}{k}: {_yaml_scalar(v)}')
elif isinstance(obj, list):
for item in obj:
if isinstance(item, dict):
first = True
for k, v in item.items():
prefix = f'{sp}- ' if first else f'{sp} '
if isinstance(v, (dict, list)):
lines.append(f'{prefix}{k}:')
lines.append(_dump_simple_yaml(v, indent + 2))
else:
lines.append(f'{prefix}{k}: {_yaml_scalar(v)}')
first = False
else:
lines.append(f'{sp}- {_yaml_scalar(item)}')
return '\n'.join(l for l in lines if l is not None)
def _yaml_scalar(v):
if isinstance(v, bool):
return 'true' if v else 'false'
if v is None:
return 'null'
if isinstance(v, (int, float)):
return str(v)
s = str(v)
if not s or any(c in s for c in ':#{}[],&*?|-<>=!%@`"\' \n') or s != s.strip():
return json.dumps(s, ensure_ascii=False)
return s
# ---------- rrpp_client:旧格式(仅 http) ----------
def countries_to_rrpp_client(countries):
"""返回旧格式节点列表(仅 http 协议),保证 RRPP 自建客户端兼容性。
格式:domain/port/country/username/password/remark(remark = provider + traffic)"""
out = []
for c in countries:
m = c.get('methods', {}).get('http')
if not m:
continue
remark = f"{c.get('provider', '')} {c.get('traffic', '')}".strip()
if c.get('ipv6_only'):
remark += ' (IPv6)'
out.append({
'domain': c['domain'],
'port': m['port'],
'country': c['country'],
'username': m['username'],
'password': m['password'],
'remark': remark,
})
return out
# ---------- method 分发 ----------
def _make_rrpp_client(countries, prefer=None):
return json.dumps(countries_to_rrpp_client(countries), ensure_ascii=False, indent=2).encode('utf-8')
def _make_clash(countries, prefer=None):
return countries_to_clash_yaml(countries, prefer).encode('utf-8')
def _make_v2ray(countries, prefer=None):
return countries_to_v2ray_sub(countries, prefer).encode('utf-8')
METHOD_HANDLERS = {
'rrpp_client': ('application/json', _make_rrpp_client),
'clash': ('text/yaml', _make_clash),
'v2ray': ('text/plain; charset=utf-8', _make_v2ray),
}
@app.route('/v1/rrpp/nodes_update') # 旧端点合并:等价于 method=rrpp_client
@app.route('/v1/rrpp/subscription')
def subscription():
method = request.args.get('method', '')
if request.path.rstrip('/').endswith('/nodes_update'):
method = 'rrpp_client'
user = b64url_decode(request.args.get('user', ''))
password = b64url_decode(request.args.get('password', ''))
prefer = request.args.get('prefer', '').strip().lower() or None
if not user or not password:
return '认证失败', 401
ok, err, tier = fetch_user_tier(
user, password,
client_ip=request.headers.get('X-Real-IP') or request.remote_addr or '')
if not ok:
return err, (502 if 'SSO' in err else 401)
if tier < 1:
return 'RRPP 服务未开通或未激活,请联系管理员放行', 403
handler = METHOD_HANDLERS.get(method)
if not handler:
return f'未知的订阅类型: {method}(可用: clash / v2ray / rrpp_client)', 404
try:
countries = load_countries()
except Exception:
return '节点配置加载失败,请联系管理员', 503
countries = filter_countries_by_tier(countries, tier)
if not countries:
return f'LV{tier} 暂无可用节点,请联系管理员', 503
mimetype, build = handler
return Response(build(countries, prefer), mimetype=mimetype)
if __name__ == '__main__':
# 仅本机监听,由 bj nginx 反代
app.run(host='127.0.0.1', port=5010)