Довёл до прода интеграцию Hysteria2: безопасный URI, тесты, актуализация доков и hardening env

This commit is contained in:
2026-05-08 07:14:41 +05:00
parent d2898ac10c
commit 06540087fd
6 changed files with 140 additions and 31 deletions
+45 -24
View File
@@ -8,6 +8,7 @@ import (
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"net"
"net/url"
"os"
"strconv"
@@ -255,34 +256,54 @@ func Hysteria2Url(accountId int64) (string, error) {
if err != nil {
return "", err
}
urlConfig := ""
if hysteria2Config.Obfs != nil &&
hysteria2Config.Obfs.Type != nil &&
*hysteria2Config.Obfs.Type == "salamander" &&
hysteria2Config.Obfs.Salamander != nil &&
hysteria2Config.Obfs.Salamander.Password != nil &&
*hysteria2Config.Obfs.Salamander.Password != "" {
urlConfig += fmt.Sprintf("&obfs=salamander&obfs-password=%s", *hysteria2Config.Obfs.Salamander.Password)
}
if hysteria2Config.ACME != nil &&
hysteria2Config.ACME.Domains != nil &&
len(hysteria2Config.ACME.Domains) > 0 {
urlConfig += fmt.Sprintf("&sni=%s", hysteria2Config.ACME.Domains[0])
}
urlConfig += "&insecure=0"
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil {
return "", err
}
if *hysteria2ConfigRemark.Value != "" {
urlConfig += fmt.Sprintf("#%s", *hysteria2ConfigRemark.Value)
remark := ""
if hysteria2ConfigRemark.Value != nil {
remark = *hysteria2ConfigRemark.Value
}
if urlConfig != "" {
urlConfig = "/?" + strings.TrimPrefix(urlConfig, "&")
obfsType := ""
obfsPassword := ""
if hysteria2Config.Obfs != nil &&
hysteria2Config.Obfs.Type != nil &&
hysteria2Config.Obfs.Salamander != nil &&
hysteria2Config.Obfs.Salamander.Password != nil {
obfsType = *hysteria2Config.Obfs.Type
obfsPassword = *hysteria2Config.Obfs.Salamander.Password
}
return fmt.Sprintf("hysteria2://%s@%s:%d", *account.ConPass, hostname, port) + urlConfig, nil
sni := ""
if hysteria2Config.ACME != nil && len(hysteria2Config.ACME.Domains) > 0 {
sni = hysteria2Config.ACME.Domains[0]
}
return buildHysteria2Url(*account.ConPass, hostname, port, obfsType, obfsPassword, sni, remark), nil
}
func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
query := url.Values{}
if obfsType == "salamander" && obfsPassword != "" {
query.Set("obfs", "salamander")
query.Set("obfs-password", obfsPassword)
}
if sni != "" {
query.Set("sni", sni)
}
query.Set("insecure", "0")
u := url.URL{
Scheme: "hysteria2",
User: url.User(conPass),
Host: net.JoinHostPort(hostname, strconv.Itoa(port)),
Path: "/",
RawQuery: query.Encode(),
}
if strings.TrimSpace(remark) != "" {
u.Fragment = remark
}
return u.String()
}
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"net/url"
"strings"
"testing"
)
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
raw := buildHysteria2Url(
"u@ser:#&=+ pass",
"example.com",
443,
"salamander",
"obf+s&pass=@x",
"exa mple.com",
"my remark #1",
)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
}
if parsed.Scheme != "hysteria2" {
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
}
if parsed.User == nil {
t.Fatal("expected userinfo to be present")
}
if parsed.User.Username() != "u@ser:#&=+ pass" {
t.Fatalf("expected decoded userinfo to match source, got %q", parsed.User.Username())
}
q := parsed.Query()
if q.Get("obfs") != "salamander" {
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
}
if q.Get("obfs-password") != "obf+s&pass=@x" {
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
}
if q.Get("sni") != "exa mple.com" {
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
}
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if parsed.Fragment != "my remark #1" {
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
}
if strings.Contains(raw, "u@ser:#&=+ pass") {
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
}
}
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("expected valid url, got error: %v", err)
}
if parsed.Host != "example.com:8443" {
t.Fatalf("unexpected host: %s", parsed.Host)
}
q := parsed.Query()
if q.Get("insecure") != "0" {
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
}
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
}
}
+2 -2
View File
@@ -40,8 +40,8 @@
## Что фиксировать в `post-install.env`
Минимум:
- `HY2_BANDWIDTH_UP_Mbps`
- `HY2_BANDWIDTH_DOWN_Mbps`
- `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN`
- `HY2_IGNORE_CLIENT_BANDWIDTH`
Дополнительно фиксируется `HY2_VERSION` как фактически установленная версия Hysteria2.
+2 -2
View File
@@ -83,8 +83,8 @@
- `HY2_TRAFFIC_STATS_LISTEN`
- `HY2_OBFS_TYPE`
- `HY2_OBFS_PASSWORD`
- `HY2_BANDWIDTH_UP_Mbps`
- `HY2_BANDWIDTH_DOWN_Mbps`
- `HY2_BANDWIDTH_UP`
- `HY2_BANDWIDTH_DOWN`
- `HY2_IGNORE_CLIENT_BANDWIDTH`
- `HY2_CONFIG_PATH`
+1 -1
View File
@@ -88,7 +88,7 @@ sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml
curl -sS -X POST \
-H 'Content-Type: application/json' \
--data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' \
--data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' \
http://127.0.0.1:8080/hui/hysteria2/auth
curl -sS \
+16 -2
View File
@@ -143,6 +143,14 @@ function normalizeFixedHysteriaObfsType(value: string | undefined): "salamander"
return "salamander";
}
function normalizeYamlSafeSecret(name: string, value: string): string {
const v = requireValue(name, value);
if (/["\n\r]/.test(v)) {
throw new Error(`${name} contains forbidden characters for HY2XS YAML profile`);
}
return v;
}
function normalizeSafeAbsolutePath(name: string, value: string, options?: { disallowTmp?: boolean }): string {
const v = value.trim();
if (!v.startsWith("/")) {
@@ -205,9 +213,15 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
),
hysteriaTrafficStatsPort: trafficStatsPort,
hysteriaTrafficStatsSecret: valueOrGenerate(env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET),
hysteriaTrafficStatsSecret: normalizeYamlSafeSecret(
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET",
valueOrGenerate(env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET)
),
hysteriaObfsType,
hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)),
hysteriaObfsPassword: normalizeYamlSafeSecret(
"HY2XS_HYSTERIA_OBFS_PASSWORD",
valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)
),
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(