feat: 接入 AnyTLS 节点托管和用户流量同步
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { decryptText } from "@/lib/crypto";
|
||||
import { getEnv } from "@/lib/env";
|
||||
import type { Hy2UserRow, Hy2NodeRow } from "@/lib/store";
|
||||
|
||||
export const SING_BOX_VERSION = "1.14.2";
|
||||
export function anytlsPassword(
|
||||
user: Pick<Hy2UserRow, "auth_id" | "auth_secret_encrypted">,
|
||||
) {
|
||||
const secret = decryptText(user.auth_secret_encrypted);
|
||||
if (!secret) throw new Error("user_secret_unavailable");
|
||||
return createHmac("sha256", getEnv().SESSION_SECRET)
|
||||
.update(`anytls\0${user.auth_id}\0${secret}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
export function buildSingBoxConfig(node: Hy2NodeRow, users: Hy2UserRow[]) {
|
||||
return {
|
||||
log: { level: "warn", timestamp: true },
|
||||
inbounds: [
|
||||
{
|
||||
type: "anytls",
|
||||
tag: "anytls-in",
|
||||
listen: "::",
|
||||
listen_port: Number(node.hy2_listen.replace(/^:/, "")),
|
||||
users: users.map((u) => ({
|
||||
name: u.auth_id,
|
||||
password: anytlsPassword(u),
|
||||
})),
|
||||
tls: {
|
||||
enabled: true,
|
||||
certificate_path: "/etc/hy2-panel-anytls/server.crt",
|
||||
key_path: "/etc/hy2-panel-anytls/server.key",
|
||||
},
|
||||
},
|
||||
],
|
||||
outbounds: [{ type: "direct", tag: "direct" }],
|
||||
experimental: {
|
||||
v2ray_api: {
|
||||
listen: node.traffic_stats_listen,
|
||||
stats: { enabled: true, users: users.map((u) => u.auth_id) },
|
||||
},
|
||||
clash_api: {
|
||||
external_controller: new URL(node.traffic_stats_url).host,
|
||||
secret: node.traffic_stats_secret,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -199,6 +199,17 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
|
||||
node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
`,
|
||||
`
|
||||
ALTER TABLE node_installations ADD COLUMN protocol TEXT NOT NULL DEFAULT 'hy2';
|
||||
CREATE TABLE node_agent_counters (
|
||||
node_id INTEGER NOT NULL REFERENCES hy2_nodes(id) ON DELETE CASCADE,
|
||||
epoch TEXT NOT NULL,
|
||||
auth_id TEXT NOT NULL,
|
||||
tx INTEGER NOT NULL DEFAULT 0,
|
||||
rx INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(node_id, epoch, auth_id)
|
||||
);
|
||||
`,
|
||||
];
|
||||
|
||||
export function migrateDatabase(db: Database.Database) {
|
||||
|
||||
+10
-2
@@ -1,3 +1,4 @@
|
||||
import { SING_BOX_VERSION } from "@/lib/anytls";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { decryptText } from "@/lib/crypto";
|
||||
@@ -11,11 +12,18 @@ export function buildInstallScript(job: Installation) {
|
||||
TOKEN: token,
|
||||
INSTALL_ID: job.id,
|
||||
SLUG: job.slug,
|
||||
SING_BOX_VERSION,
|
||||
};
|
||||
return readFileSync(
|
||||
path.join(process.cwd(), "lib/templates/install-node.sh"),
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"lib", "templates",
|
||||
job.protocol === "anytls"
|
||||
? "install-anytls.sh"
|
||||
: "install-node.sh",
|
||||
),
|
||||
"utf8",
|
||||
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG)__/g, (_, key) =>
|
||||
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG|SING_BOX_VERSION)__/g, (_, key) =>
|
||||
quote(values[key]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { ensureDb } from "@/lib/db";
|
||||
import { ProvisionError } from "@/lib/certificates";
|
||||
import {
|
||||
applyNodeSync,
|
||||
getUserLifecycleState,
|
||||
type Hy2NodeRow,
|
||||
type Hy2UserRow,
|
||||
} from "@/lib/store";
|
||||
import { buildSingBoxConfig } from "@/lib/anytls";
|
||||
|
||||
const counter = z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER / 2);
|
||||
export const reportSchema = z
|
||||
.object({
|
||||
epoch: z.string().regex(/^[a-zA-Z0-9:._-]{1,160}$/),
|
||||
traffic: z
|
||||
.record(
|
||||
z.string().min(1).max(128),
|
||||
z.object({ tx: counter, rx: counter }).strict(),
|
||||
)
|
||||
.refine((v) => Object.keys(v).length <= 10000),
|
||||
streamCount: z.number().int().min(0).max(1000000),
|
||||
})
|
||||
.strict();
|
||||
export function eligibleAnyTLSUsers(users: Hy2UserRow[]) {
|
||||
return users.filter(
|
||||
(u) =>
|
||||
(u.is_admin ? Boolean(u.enabled) : getUserLifecycleState(u).ok) &&
|
||||
u.auth_secret_encrypted,
|
||||
);
|
||||
}
|
||||
export async function syncAnyTLSNode(
|
||||
slug: string,
|
||||
token: string,
|
||||
input: unknown,
|
||||
) {
|
||||
const data = reportSchema.safeParse(input);
|
||||
if (!data.success) throw new ProvisionError("无效节点报告。");
|
||||
const db = await ensureDb();
|
||||
return db
|
||||
.transaction(() => {
|
||||
const node = db
|
||||
.prepare("SELECT * FROM hy2_nodes WHERE slug=? AND node_type='anytls'")
|
||||
.get(slug) as Hy2NodeRow | undefined;
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/.test(token) ||
|
||||
!node ||
|
||||
token.length !== node.auth_token.length ||
|
||||
!timingSafeEqual(Buffer.from(token), Buffer.from(node.auth_token))
|
||||
)
|
||||
throw new ProvisionError("节点凭据无效。");
|
||||
const now = new Date().toISOString();
|
||||
const traffic: Record<string, { tx: number; rx: number }> = {};
|
||||
const lookup = db.prepare(
|
||||
"SELECT tx,rx FROM node_agent_counters WHERE node_id=? AND epoch=? AND auth_id=?",
|
||||
);
|
||||
const save =
|
||||
db.prepare(`INSERT INTO node_agent_counters(node_id,epoch,auth_id,tx,rx) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(node_id,epoch,auth_id) DO UPDATE SET tx=MAX(tx,excluded.tx),rx=MAX(rx,excluded.rx)`);
|
||||
for (const [authId, counters] of Object.entries(data.data.traffic)) {
|
||||
if (!db.prepare("SELECT 1 FROM hy2_users WHERE auth_id=?").get(authId))
|
||||
continue;
|
||||
const previous = lookup.get(node.id, data.data.epoch, authId) as
|
||||
| { tx: number; rx: number }
|
||||
| undefined;
|
||||
const tx = Math.max(0, counters.tx - (previous?.tx ?? 0));
|
||||
const rx = Math.max(0, counters.rx - (previous?.rx ?? 0));
|
||||
if (tx || rx) traffic[authId] = { tx, rx };
|
||||
save.run(node.id, data.data.epoch, authId, counters.tx, counters.rx);
|
||||
}
|
||||
applyNodeSync(db, {
|
||||
node,
|
||||
startedAt: node.last_sync_ok_at || now,
|
||||
finishedAt: now,
|
||||
traffic,
|
||||
online: {},
|
||||
streamCount: data.data.streamCount,
|
||||
kickedAuthIds: [],
|
||||
});
|
||||
const users = node.enabled
|
||||
? eligibleAnyTLSUsers(
|
||||
db
|
||||
.prepare("SELECT * FROM hy2_users ORDER BY id")
|
||||
.all() as Hy2UserRow[],
|
||||
)
|
||||
: [];
|
||||
const expires = users
|
||||
.filter((u) => !u.is_admin && u.expires_at)
|
||||
.map((u) => Date.parse(u.expires_at!));
|
||||
const leaseSeconds = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
90,
|
||||
...expires.map((v) => Math.floor((v - Date.now()) / 1000)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
config: buildSingBoxConfig(node, users),
|
||||
leaseSeconds,
|
||||
intervalSeconds: 15,
|
||||
};
|
||||
})
|
||||
.immediate();
|
||||
}
|
||||
+68
-10
@@ -1,3 +1,7 @@
|
||||
import { buildSingBoxConfig } from "@/lib/anytls";
|
||||
import { eligibleAnyTLSUsers } from "@/lib/node-agent";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { stringify } from "yaml";
|
||||
@@ -9,7 +13,7 @@ import {
|
||||
validateCertificate,
|
||||
type ManagedCertificate,
|
||||
} from "@/lib/certificates";
|
||||
import type { Hy2NodeRow } from "@/lib/store";
|
||||
import type { Hy2NodeRow, Hy2UserRow } from "@/lib/store";
|
||||
|
||||
export const portSchema = z
|
||||
.object({
|
||||
@@ -22,13 +26,27 @@ export const portSchema = z
|
||||
statsListenPort: z.number().int().min(1).max(65535),
|
||||
})
|
||||
.strict();
|
||||
export const anytlsPortSchema = z
|
||||
.object({
|
||||
domain: portSchema.shape.domain,
|
||||
port: portSchema.shape.hy2Port,
|
||||
listenPort: portSchema.shape.hy2Port,
|
||||
statsPort: portSchema.shape.hy2Port,
|
||||
controlPort: portSchema.shape.hy2Port,
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => new Set([v.listenPort, v.statsPort, v.controlPort]).size === 3,
|
||||
);
|
||||
export const installationSchema = z.object({
|
||||
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
protocol: z.enum(["hy2", "anytls"]).optional(),
|
||||
certificateId: z.coerce.number().int().positive(),
|
||||
});
|
||||
export type Installation = {
|
||||
id: string;
|
||||
protocol?: "hy2" | "anytls";
|
||||
token_hash: string;
|
||||
token_encrypted: string;
|
||||
certificate_id: number;
|
||||
@@ -91,8 +109,8 @@ export async function createInstallation(
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO node_installations
|
||||
(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at,protocol)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
).run(
|
||||
id,
|
||||
hash(token),
|
||||
@@ -105,6 +123,7 @@ export async function createInstallation(
|
||||
origin.origin,
|
||||
new Date(Date.now() + 24 * 3600_000).toISOString(),
|
||||
now,
|
||||
input.protocol || "hy2",
|
||||
);
|
||||
}).immediate();
|
||||
return id;
|
||||
@@ -114,7 +133,7 @@ export async function listInstallations() {
|
||||
const db = await ensureDb();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id,
|
||||
`SELECT id,protocol,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id,
|
||||
(SELECT name FROM managed_certificates WHERE id=certificate_id) AS certificate_name
|
||||
FROM node_installations ORDER BY created_at DESC`,
|
||||
)
|
||||
@@ -182,12 +201,25 @@ export async function provisionNode(
|
||||
}
|
||||
if (job.completed_at)
|
||||
throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。");
|
||||
const parsed = portSchema.safeParse(rawPorts);
|
||||
const parsed =
|
||||
job.protocol === "anytls"
|
||||
? anytlsPortSchema.safeParse(rawPorts)
|
||||
: portSchema.safeParse(rawPorts);
|
||||
if (!parsed.success)
|
||||
throw new ProvisionError(
|
||||
"域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。",
|
||||
);
|
||||
const ports = parsed.data,
|
||||
const ports =
|
||||
"listenPort" in parsed.data
|
||||
? {
|
||||
domain: parsed.data.domain,
|
||||
hy2Port: parsed.data.port,
|
||||
hy2ListenPort: parsed.data.listenPort,
|
||||
statsPort: parsed.data.statsPort,
|
||||
statsListenPort: parsed.data.statsPort,
|
||||
controlPort: parsed.data.controlPort,
|
||||
}
|
||||
: { ...parsed.data, controlPort: 0 },
|
||||
portsJson = JSON.stringify(ports);
|
||||
if (job.ports_json && job.ports_json !== portsJson)
|
||||
throw new ProvisionError(
|
||||
@@ -209,14 +241,16 @@ export async function provisionNode(
|
||||
.prepare(
|
||||
`INSERT INTO hy2_nodes
|
||||
(slug,name,auth_token,traffic_stats_url,traffic_stats_secret,enabled,created_at,updated_at,
|
||||
server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen)
|
||||
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)`,
|
||||
server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen,node_type)
|
||||
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
job.slug,
|
||||
job.name,
|
||||
randomBytes(32).toString("hex"),
|
||||
`http://${host}:${ports.statsPort}`,
|
||||
job.protocol === "anytls"
|
||||
? `http://127.0.0.1:${ports.controlPort}`
|
||||
: `http://${host}:${ports.statsPort}`,
|
||||
randomBytes(32).toString("hex"),
|
||||
now,
|
||||
now,
|
||||
@@ -225,7 +259,10 @@ export async function provisionNode(
|
||||
job.name,
|
||||
host,
|
||||
`:${ports.hy2ListenPort}`,
|
||||
`:${ports.statsListenPort}`,
|
||||
job.protocol === "anytls"
|
||||
? `127.0.0.1:${ports.statsPort}`
|
||||
: `:${ports.statsListenPort}`,
|
||||
job.protocol || "hy2",
|
||||
).lastInsertRowid,
|
||||
);
|
||||
db.prepare(
|
||||
@@ -235,6 +272,27 @@ export async function provisionNode(
|
||||
const node = db
|
||||
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
||||
.get(nodeId) as Hy2NodeRow;
|
||||
if (job.protocol === "anytls") {
|
||||
const users = eligibleAnyTLSUsers(
|
||||
db
|
||||
.prepare("SELECT * FROM hy2_users ORDER BY id")
|
||||
.all() as Hy2UserRow[],
|
||||
);
|
||||
return {
|
||||
config: JSON.stringify(buildSingBoxConfig(node, users), null, 2),
|
||||
certificate: cert.certificate_pem,
|
||||
privateKey: key,
|
||||
agent: { panel: job.origin, slug: job.slug, token: node.auth_token },
|
||||
agentSource: readFileSync(
|
||||
path.join(process.cwd(), "lib/templates/anytls-agent.py"),
|
||||
"utf8",
|
||||
),
|
||||
statsProto: readFileSync(
|
||||
path.join(process.cwd(), "lib/templates/stats.proto"),
|
||||
"utf8",
|
||||
),
|
||||
};
|
||||
}
|
||||
const config = stringify({
|
||||
listen: node.hy2_listen,
|
||||
tls: {
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ export type Hy2UserRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type NodeType = "hy2" | "custom";
|
||||
export type NodeType = "hy2" | "custom" | "anytls";
|
||||
|
||||
export type Hy2NodeRow = {
|
||||
id: number;
|
||||
@@ -141,7 +141,7 @@ export function buildTrafficStatsUrl(serverHost: string, listen: string) {
|
||||
}
|
||||
|
||||
export function normalizeNodeType(value: string | null | undefined): NodeType {
|
||||
return value === "custom" ? "custom" : "hy2";
|
||||
return value === "custom" ? "custom" : value === "anytls" ? "anytls" : "hy2";
|
||||
}
|
||||
|
||||
export function getNodeProxyName(node: {
|
||||
|
||||
+18
-2
@@ -1,3 +1,4 @@
|
||||
import { anytlsPassword } from "@/lib/anytls";
|
||||
import { renderCustomProxyYaml } from "@/lib/custom-proxy";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -52,11 +53,26 @@ function renderHy2Proxy(node: Hy2NodeRow, authString: string) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderSubscriptionProxy(node: Hy2NodeRow, authString: string) {
|
||||
function renderSubscriptionProxy(
|
||||
node: Hy2NodeRow,
|
||||
authString: string,
|
||||
user: Hy2UserRow,
|
||||
) {
|
||||
if (node.node_type === "custom") {
|
||||
return renderCustomProxyYaml(node.custom_config_yaml);
|
||||
}
|
||||
|
||||
if (node.node_type === "anytls")
|
||||
return [
|
||||
`- name: ${JSON.stringify(node.client_name || node.name)}`,
|
||||
" type: anytls",
|
||||
` server: ${JSON.stringify(node.server_host)}`,
|
||||
` port: ${node.server_port}`,
|
||||
` password: ${JSON.stringify(anytlsPassword(user))}`,
|
||||
` sni: ${JSON.stringify(node.sni || node.server_host)}`,
|
||||
" skip-cert-verify: false",
|
||||
" udp: true",
|
||||
].join("\n");
|
||||
return renderHy2Proxy(node, authString);
|
||||
}
|
||||
|
||||
@@ -211,7 +227,7 @@ export function buildSubscriptionConfig(input: {
|
||||
);
|
||||
const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node));
|
||||
const proxiesBlock = orderedEligibleNodes
|
||||
.map((node) => renderSubscriptionProxy(node, authString))
|
||||
.map((node) => renderSubscriptionProxy(node, authString, input.user))
|
||||
.join("\n");
|
||||
const defaultTemplate =
|
||||
input.settings.template.trimEnd() || readDefaultTemplate();
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HY2 Panel AnyTLS agent. Local-only core APIs; HTTPS outbound control plane."""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import grpc
|
||||
import stats_pb2
|
||||
|
||||
ROOT = Path('/etc/hy2-panel-anytls')
|
||||
STATE = Path('/var/lib/hy2-panel-anytls')
|
||||
BINARY = '/usr/local/lib/hy2-panel-anytls/sing-box'
|
||||
UNIT = 'hy2-panel-anytls.service'
|
||||
|
||||
|
||||
def atomic_json(file, value):
|
||||
temporary = file.with_suffix('.tmp')
|
||||
with open(temporary, 'w') as handle:
|
||||
os.chmod(temporary, 0o600)
|
||||
json.dump(value, handle, sort_keys=True)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, file)
|
||||
|
||||
|
||||
def core(command):
|
||||
subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def read_json(file):
|
||||
return json.loads(file.read_text())
|
||||
|
||||
|
||||
def epoch():
|
||||
pid = subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip()
|
||||
if pid == '0':
|
||||
return None
|
||||
# PID + boot ID + start ticks survives agent restarts without re-counting.
|
||||
start = Path('/proc/' + pid + '/stat').read_text().rsplit(')', 1)[1].split()[19]
|
||||
return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + ':' + pid + ':' + start
|
||||
|
||||
|
||||
def snapshot():
|
||||
current = epoch()
|
||||
if current is None:
|
||||
return None
|
||||
config = read_json(ROOT / 'config.json')
|
||||
experimental = config['experimental']
|
||||
with grpc.insecure_channel(experimental['v2ray_api']['listen']) as channel:
|
||||
query = channel.unary_unary(
|
||||
'/v2ray.core.app.stats.command.StatsService/QueryStats',
|
||||
request_serializer=stats_pb2.QueryStatsRequest.SerializeToString,
|
||||
response_deserializer=stats_pb2.QueryStatsResponse.FromString)
|
||||
result = query(stats_pb2.QueryStatsRequest(pattern='user>>>', reset=False), timeout=5)
|
||||
traffic = {}
|
||||
for stat in result.stat:
|
||||
parts = stat.name.split('>>>')
|
||||
if len(parts) == 4 and parts[0] == 'user' and parts[2] == 'traffic':
|
||||
value = traffic.setdefault(parts[1], {'tx': 0, 'rx': 0})
|
||||
# HY2 ledgers use tx=server->client, rx=client->server.
|
||||
if parts[3] == 'uplink':
|
||||
value['rx'] = int(stat.value)
|
||||
elif parts[3] == 'downlink':
|
||||
value['tx'] = int(stat.value)
|
||||
clash = experimental['clash_api']
|
||||
request = urllib.request.Request('http://' + clash['external_controller'] + '/connections',
|
||||
headers={'Authorization': 'Bearer ' + clash['secret']})
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
streams = len(json.load(response).get('connections', []))
|
||||
if epoch() != current:
|
||||
raise RuntimeError('core_restarted_during_snapshot')
|
||||
return {'epoch': current, 'traffic': traffic, 'streamCount': streams}
|
||||
|
||||
|
||||
def remember(state):
|
||||
sample = snapshot()
|
||||
if sample:
|
||||
state['pending'][sample['epoch']] = sample
|
||||
atomic_json(STATE / 'state.json', state)
|
||||
return sample
|
||||
|
||||
|
||||
def sync(credentials, sample):
|
||||
request = urllib.request.Request(credentials['panel'] + '/api/agent/' + credentials['slug'] + '/sync',
|
||||
data=json.dumps(sample).encode(), method='POST',
|
||||
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']})
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def apply_config(config, state):
|
||||
previous = read_json(ROOT / 'config.json')
|
||||
if previous != config:
|
||||
# Capture one last cumulative sample before replacing a core process.
|
||||
remember(state)
|
||||
staged = ROOT / 'next.json'
|
||||
atomic_json(staged, config)
|
||||
checked = subprocess.run([BINARY, 'check', '-c', str(staged)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if checked.returncode:
|
||||
core('stop')
|
||||
raise RuntimeError('invalid_core_configuration')
|
||||
os.replace(staged, ROOT / 'config.json')
|
||||
core('restart')
|
||||
else:
|
||||
core('start')
|
||||
|
||||
|
||||
def run():
|
||||
STATE.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
lock = open(STATE / 'agent.lock', 'w')
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
credentials = read_json(ROOT / 'agent.json')
|
||||
state_file = STATE / 'state.json'
|
||||
state = read_json(state_file) if state_file.exists() else {'pending': {}}
|
||||
# No cached lease is trusted after an agent restart.
|
||||
deadline = 0
|
||||
while True:
|
||||
try:
|
||||
if time.monotonic() >= deadline:
|
||||
try:
|
||||
remember(state)
|
||||
finally:
|
||||
core('stop')
|
||||
else:
|
||||
remember(state)
|
||||
if len(state['pending']) > 256:
|
||||
core('stop')
|
||||
raise RuntimeError('report_backlog_full')
|
||||
reports = list(state['pending'].items())
|
||||
if not reports:
|
||||
reports = [('heartbeat', {'epoch': 'heartbeat', 'traffic': {}, 'streamCount': 0})]
|
||||
desired = None
|
||||
for report_epoch, report in reports:
|
||||
desired = sync(credentials, report)
|
||||
state['pending'].pop(report_epoch, None)
|
||||
atomic_json(state_file, state)
|
||||
apply_config(desired['config'], state)
|
||||
deadline = time.monotonic() + min(90, max(1, desired['leaseSeconds']))
|
||||
print('同步成功', flush=True)
|
||||
except Exception as error:
|
||||
# Do not log credentials, URLs, user lists or config contents.
|
||||
print('同步失败:' + type(error).__name__, flush=True)
|
||||
if isinstance(error, urllib.error.HTTPError) and error.code in (400, 401, 403, 404):
|
||||
deadline = 0
|
||||
core('stop')
|
||||
if time.monotonic() >= deadline:
|
||||
core('stop')
|
||||
time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
os.umask(0o077)
|
||||
try:
|
||||
run()
|
||||
except Exception as error:
|
||||
core('stop')
|
||||
print('管理程序停止:' + type(error).__name__, flush=True)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
PANEL=__PANEL__
|
||||
TOKEN=__TOKEN__
|
||||
INSTALL_ID=__INSTALL_ID__
|
||||
SLUG=__SLUG__
|
||||
VERSION=__SING_BOX_VERSION__
|
||||
echo "安装 sing-box $VERSION(带用户统计模块)"
|
||||
DOMAIN='' PORT='' LISTEN_PORT=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
[ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; }
|
||||
case "$1" in
|
||||
--domain) DOMAIN=$2;; --port) PORT=$2;; --listen-port) LISTEN_PORT=$2;;
|
||||
*) echo "未知参数:$1"; exit 1;;
|
||||
esac
|
||||
shift 2
|
||||
done
|
||||
[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; }
|
||||
ROOT=/etc/hy2-panel-anytls
|
||||
if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-anytls.service ]; then
|
||||
[ "$(cat "$ROOT/install-id" 2>/dev/null || true)" = "$INSTALL_ID" ] || { echo '已有其他 AnyTLS 安装,停止以避免覆盖。'; exit 1; }
|
||||
fi
|
||||
[ -n "$DOMAIN" ] || read -r -p '域名前缀(例如 jp2)或完整域名:' DOMAIN </dev/tty
|
||||
DOMAIN=$(printf '%s' "$DOMAIN" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then DOMAIN="$DOMAIN.p.xn--876a.net"; fi
|
||||
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$ ]] || { echo '请输入 .p.xn--876a.net 下的单级域名。'; exit 1; }
|
||||
[ -n "$PORT" ] || read -r -p 'AnyTLS 公网 TCP 端口:' PORT </dev/tty
|
||||
if [ -z "$LISTEN_PORT" ] && [ -t 0 ]; then read -r -p "本机 TCP 监听端口(回车使用 $PORT):" LISTEN_PORT </dev/tty; fi
|
||||
LISTEN_PORT=${LISTEN_PORT:-$PORT}
|
||||
for port in "$PORT" "$LISTEN_PORT"; do
|
||||
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then echo '端口须为 1–65535。'; exit 1; fi
|
||||
done
|
||||
case "$(uname -m)" in x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;; *) echo '暂支持 x86_64 和 ARM64。'; exit 1;; esac
|
||||
if command -v apt-get >/dev/null; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y curl jq ca-certificates python3 python3-venv openssl
|
||||
elif command -v dnf >/dev/null; then dnf install -y curl jq ca-certificates python3 python3-pip openssl
|
||||
else echo '当前安装脚本支持 Debian/Ubuntu 或使用 dnf 的发行版。'; exit 1; fi
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers"
|
||||
# Panel serves a pinned, unmodified upstream build with the V2Ray stats module.
|
||||
curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "$PANEL/api/install/sing-box/$ARCH" -o "$WORK/sing-box"
|
||||
curl -fsSL --retry 3 --max-time 60 "$PANEL/api/install/sing-box/$ARCH?sha256" -o "$WORK/checksum"
|
||||
printf '%s %s\n' "$(cat "$WORK/checksum")" "$WORK/sing-box" | sha256sum -c -
|
||||
# Retain local management ports on retries. These are never exposed publicly.
|
||||
if [ -f "$ROOT/ports.json" ]; then
|
||||
jq -e --arg domain "$DOMAIN" --argjson port "$PORT" --argjson listen "$LISTEN_PORT" '.domain == $domain and .port == $port and .listenPort == $listen' "$ROOT/ports.json" >/dev/null || { echo '重试安装请使用原域名和端口;变更配置需生成新的安装记录。'; exit 1; }
|
||||
cp "$ROOT/ports.json" "$WORK/ports.json"
|
||||
else
|
||||
python3 - "$PORT" "$LISTEN_PORT" "$DOMAIN" > "$WORK/ports.json" <<'PY'
|
||||
import json,socket,sys
|
||||
sockets=[]
|
||||
for _ in range(2):
|
||||
s=socket.socket();s.bind(('127.0.0.1',0));sockets.append(s)
|
||||
print(json.dumps(dict(domain=sys.argv[3],port=int(sys.argv[1]),listenPort=int(sys.argv[2]),statsPort=sockets[0].getsockname()[1],controlPort=sockets[1].getsockname()[1])))
|
||||
PY
|
||||
fi
|
||||
jq -n --slurpfile ports "$WORK/ports.json" '{action:"prepare",ports:$ports[0]}' > "$WORK/request"
|
||||
api() {
|
||||
local status
|
||||
status=$(curl -sS --connect-timeout 15 --max-time 60 -o "$WORK/response" -w '%{http_code}' -H @"$WORK/headers" -H 'Content-Type: application/json' --data-binary @"$WORK/request" "$PANEL/api/install")
|
||||
if [ "$status" != 200 ]; then jq -r '.error // "平台请求失败"' "$WORK/response" >&2; return 1; fi
|
||||
}
|
||||
api
|
||||
systemctl stop hy2-panel-anytls-agent.service hy2-panel-anytls.service 2>/dev/null || true
|
||||
install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-anytls /var/lib/hy2-panel-anytls
|
||||
printf '%s' "$INSTALL_ID" > "$ROOT/install-id"
|
||||
install -m 600 "$WORK/ports.json" "$ROOT/ports.json"
|
||||
jq -er '.config' "$WORK/response" > "$ROOT/config.json"
|
||||
jq -er '.certificate' "$WORK/response" > "$ROOT/server.crt"
|
||||
jq -er '.privateKey' "$WORK/response" > "$ROOT/server.key"
|
||||
jq -e '.agent' "$WORK/response" > "$ROOT/agent.json"
|
||||
jq -er '.agentSource' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/agent.py
|
||||
jq -er '.statsProto' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/stats.proto
|
||||
install -m 755 "$WORK/sing-box" /usr/local/lib/hy2-panel-anytls/sing-box
|
||||
/usr/local/lib/hy2-panel-anytls/sing-box check -c "$ROOT/config.json"
|
||||
python3 -m venv /usr/local/lib/hy2-panel-anytls/venv
|
||||
/usr/local/lib/hy2-panel-anytls/venv/bin/pip -q install 'grpcio==1.76.0' 'grpcio-tools==1.76.0'
|
||||
/usr/local/lib/hy2-panel-anytls/venv/bin/python -m grpc_tools.protoc -I /usr/local/lib/hy2-panel-anytls --python_out=/usr/local/lib/hy2-panel-anytls /usr/local/lib/hy2-panel-anytls/stats.proto
|
||||
cat > /etc/systemd/system/hy2-panel-anytls.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=AnyTLS sing-box managed by HY2 Panel
|
||||
After=network-online.target
|
||||
[Service]
|
||||
ExecStart=/usr/local/lib/hy2-panel-anytls/sing-box run -c /etc/hy2-panel-anytls/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=true
|
||||
UNIT
|
||||
cat > /etc/systemd/system/hy2-panel-anytls-agent.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=HY2 Panel AnyTLS sync agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
ExecStart=/usr/local/lib/hy2-panel-anytls/venv/bin/python /usr/local/lib/hy2-panel-anytls/agent.py
|
||||
ExecStopPost=/bin/systemctl stop hy2-panel-anytls.service
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/hy2-panel-anytls /var/lib/hy2-panel-anytls
|
||||
PrivateTmp=true
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl restart hy2-panel-anytls.service
|
||||
CONTROL=$(jq -r '.controlPort' "$ROOT/ports.json")
|
||||
jq -er '.experimental.clash_api.secret | "Authorization: Bearer " + .' "$ROOT/config.json" > "$WORK/control-header"
|
||||
READY=0
|
||||
for attempt in {1..15}; do
|
||||
if curl -fsS --max-time 2 -H @"$WORK/control-header" "http://127.0.0.1:$CONTROL/connections" -o /dev/null; then READY=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$READY" -eq 1 ] || { systemctl stop hy2-panel-anytls.service; echo 'sing-box 健康检查失败。'; exit 1; }
|
||||
printf '{"action":"complete"}' > "$WORK/request"
|
||||
for attempt in {1..3}; do
|
||||
if api; then break; fi
|
||||
[ "$attempt" -lt 3 ] || { systemctl stop hy2-panel-anytls.service; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
# Only the agent starts at boot; it requires a valid panel lease before starting the core.
|
||||
systemctl enable --now hy2-panel-anytls-agent.service
|
||||
echo "AnyTLS 节点 $SLUG 已安装。TCP $PORT → $LISTEN_PORT;管理端口仅监听本机。"
|
||||
echo '用户变更约 15 秒同步;应用变更会重启 sing-box 并断开现有连接。'
|
||||
@@ -0,0 +1,17 @@
|
||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
In addition, no derivative work may use the name or imply association
|
||||
with this application without prior consent.
|
||||
@@ -0,0 +1,55 @@
|
||||
// Source: SagerNet/sing-box v1.14.2, commit af6e64c3b69e6132ebaee0e1a3d24e93903f6709
|
||||
// Copyright (C) 2022 nekohasekai. GPL-3.0-or-later; see sing-box-LICENSE.
|
||||
syntax = "proto3";
|
||||
|
||||
package experimental.v2rayapi;
|
||||
option go_package = "github.com/sagernet/sing-box/experimental/v2rayapi";
|
||||
|
||||
message GetStatsRequest {
|
||||
// Name of the stat counter.
|
||||
string name = 1;
|
||||
// Whether or not to reset the counter to fetching its value.
|
||||
bool reset = 2;
|
||||
}
|
||||
|
||||
message Stat {
|
||||
string name = 1;
|
||||
int64 value = 2;
|
||||
}
|
||||
|
||||
message GetStatsResponse {
|
||||
Stat stat = 1;
|
||||
}
|
||||
|
||||
message QueryStatsRequest {
|
||||
// Deprecated, use Patterns instead
|
||||
string pattern = 1;
|
||||
bool reset = 2;
|
||||
repeated string patterns = 3;
|
||||
bool regexp = 4;
|
||||
}
|
||||
|
||||
message QueryStatsResponse {
|
||||
repeated Stat stat = 1;
|
||||
}
|
||||
|
||||
message SysStatsRequest {}
|
||||
|
||||
message SysStatsResponse {
|
||||
uint32 NumGoroutine = 1;
|
||||
uint32 NumGC = 2;
|
||||
uint64 Alloc = 3;
|
||||
uint64 TotalAlloc = 4;
|
||||
uint64 Sys = 5;
|
||||
uint64 Mallocs = 6;
|
||||
uint64 Frees = 7;
|
||||
uint64 LiveObjects = 8;
|
||||
uint64 PauseTotalNs = 9;
|
||||
uint32 Uptime = 10;
|
||||
}
|
||||
|
||||
service StatsService {
|
||||
rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {}
|
||||
rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {}
|
||||
rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {}
|
||||
}
|
||||
Reference in New Issue
Block a user