Files
hi-server/adapter/simnet.go
T
shanshanzhong147 634b5a7bd0 feat(simnet): add SimNet protocol end-to-end support
- model: SimNet protocol fields + NormalizeSimnet; type+port protocol uniqueness
- server config: OmnXT runtime config delivery via compatible() simnet case
- credentials: derive per-user psk/key_id from subscription (pkg/simnet), no new table
- subscription: adapter buildOmnxtSimnetConfigs + base64 buildOmnxtProtocolLinks + OmnXT SimNet application (migration 02161)
- UA gating: hide experimental protocols from non first-party clients (download + JSON node-list)
- admin: normalize simnet on create/update and on GET responses
- tests: 21 simnet unit tests; full suite green
2026-07-27 00:17:02 -07:00

274 lines
9.0 KiB
Go

package adapter
import (
"encoding/base64"
"net/url"
"strconv"
"strings"
"github.com/perfect-panel/server/pkg/simnet"
)
// buildOmnxtSimnetConfigs is a subscription template function (registered in
// Client.Build) that produces the per-user OmnXT SimNet JSON config array.
//
// It mirrors the Pro reference (NPanel-backend
// internal/biz/public/subscription/template.go buildOmnxtSimnetConfigs):
// - per-user simnet_psk / simnet_key_id are DERIVED from the user's
// subscription (uuid + user_subscribe.id), never stored.
// - the server PSK (key_id=0) is passed through as simnet_server_psk so the
// client SDK can derive AF path/magic with the same key material.
//
// Template usage: {{ buildOmnxtSimnetConfigs .Proxies .UserInfo .Params | toPrettyJson }}
func buildOmnxtSimnetConfigs(proxies []map[string]interface{}, userInfo User, params map[string]string) []map[string]interface{} {
result := make([]map[string]interface{}, 0)
proxyMode := strings.TrimSpace(params["proxy_mode"])
if proxyMode == "" {
proxyMode = "global"
}
dnsServers := []string{"1.1.1.1"}
if raw := strings.TrimSpace(params["dns_servers"]); raw != "" {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r'
})
parsed := make([]string, 0, len(parts))
for _, item := range parts {
if item = strings.TrimSpace(item); item != "" {
parsed = append(parsed, item)
}
}
if len(parsed) > 0 {
dnsServers = parsed
}
}
// Per-user credentials derived from the subscription record (see pkg/simnet).
userKeyID := simnet.DeriveKeyID(userInfo.SubscribeID)
userPSK := simnet.DeriveUserPSK(userInfo.Password)
for _, proxy := range proxies {
if smString(proxy["Type"]) != "simnet" {
continue
}
afEnabled := smBool(proxy["SimnetAfEnabled"])
item := map[string]interface{}{
"tag": smString(proxy["Name"]),
"server_addr": smString(proxy["Server"]),
"server_port": smInt(proxy["Port"]),
"protocol": "simnet",
"sni": smString(proxy["SNI"]),
"allow_insecure": smBool(proxy["AllowInsecure"]),
"simnet_psk": userPSK,
"simnet_key_id": userKeyID,
// Server PSK is required for AF path/magic/content-type derivation.
"simnet_server_psk": smStringOrNil(proxy["SimnetPsk"]),
"simnet_server_key_id": smInt(proxy["SimnetKeyID"]),
"simnet_ticket_id": smStringOrNil(proxy["SimnetTicketID"]),
"simnet_path": smDefaultString(smString(proxy["SimnetPath"]), "/simnet/session"),
"simnet_carrier": smDefaultString(smString(proxy["SimnetCarrier"]), "h2"),
"simnet_af_enabled": afEnabled,
"simnet_client_max_concurrent_streams": smDefaultInt(smInt(proxy["SimnetClientMaxConcurrentStreams"]), 32),
"simnet_client_max_streams_per_session": smDefaultInt(smInt(proxy["SimnetClientMaxStreamsPerSession"]), 512),
"simnet_client_session_idle_timeout_secs": smDefaultInt(smInt(proxy["SimnetClientSessionIdleTimeoutSecs"]), 90),
"simnet_client_max_udp_sessions": smDefaultInt(smInt(proxy["SimnetClientMaxUDPSessions"]), 64),
"proxy_mode": proxyMode,
"dns_servers": dnsServers,
}
if afEnabled {
item["simnet_af_path_mode"] = smDefaultString(smString(proxy["SimnetAfPathMode"]), "api")
item["simnet_af_path_prefix"] = smStringOrNil(proxy["SimnetAfPathPrefix"])
item["simnet_af_path_suffix"] = smStringOrNil(proxy["SimnetAfPathSuffix"])
item["simnet_af_magic_mode"] = smDefaultString(smString(proxy["SimnetAfMagicMode"]), "derived")
item["simnet_af_response_jitter_ms"] = smDefaultInt(smInt(proxy["SimnetAfResponseJitterMs"]), 50)
item["simnet_af_handshake_polymorphism"] = smBool(proxy["SimnetAfHandshakePolymorphism"])
item["simnet_af_settings_jitter"] = smBool(proxy["SimnetAfSettingsJitter"])
item["simnet_af_fake_header_injection"] = smBool(proxy["SimnetAfFakeHeaderInjection"])
}
result = append(result, item)
}
return result
}
func smString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func smStringOrNil(v interface{}) interface{} {
if s, ok := v.(string); ok && s != "" {
return s
}
return nil
}
func smBool(v interface{}) bool {
b, ok := v.(bool)
return ok && b
}
func smInt(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int8:
return int(n)
case int16:
return int(n)
case int32:
return int(n)
case int64:
return int(n)
case uint:
return int(n)
case uint8:
return int(n)
case uint16:
return int(n)
case uint32:
return int(n)
case uint64:
return int(n)
case float32:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func smDefaultString(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func smDefaultInt(i, def int) int {
if i == 0 {
return def
}
return i
}
// buildOmnxtProtocolLinks wraps each simnet config into a base64 "simnet://"
// link, matching the Pro reference's final delivery format (migration 02140,
// template.go buildOmnxtProtocolLinks). Template usage:
//
// {{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}
// {{- end }}
func buildOmnxtProtocolLinks(proxies []map[string]interface{}, userInfo User, params map[string]string) []string {
configs := buildOmnxtSimnetConfigs(proxies, userInfo, params)
result := make([]string, 0, len(configs))
for _, item := range configs {
serverAddr := smString(item["server_addr"])
serverPort := smInt(item["server_port"])
tag := smString(item["tag"])
if serverAddr == "" || serverPort == 0 {
continue
}
afEnabled := smBool(item["simnet_af_enabled"])
payload := map[string]interface{}{
"protocol": "simnet",
"server_addr": serverAddr,
"server_port": serverPort,
"sni": smString(item["sni"]),
"simnet_psk": smString(item["simnet_psk"]),
"simnet_key_id": smInt(item["simnet_key_id"]),
"simnet_server_psk": item["simnet_server_psk"],
"simnet_server_key_id": smInt(item["simnet_server_key_id"]),
"simnet_ticket_id": item["simnet_ticket_id"],
"simnet_path": item["simnet_path"],
"simnet_carrier": smString(item["simnet_carrier"]),
"simnet_af_enabled": afEnabled,
"simnet_client_max_concurrent_streams": smInt(item["simnet_client_max_concurrent_streams"]),
"simnet_client_max_streams_per_session": smInt(item["simnet_client_max_streams_per_session"]),
"simnet_client_session_idle_timeout_secs": smInt(item["simnet_client_session_idle_timeout_secs"]),
"simnet_client_max_udp_sessions": smInt(item["simnet_client_max_udp_sessions"]),
"proxy_mode": item["proxy_mode"],
"dns_servers": item["dns_servers"],
}
if afEnabled {
payload["simnet_af_path_mode"] = smString(item["simnet_af_path_mode"])
payload["simnet_af_path_prefix"] = item["simnet_af_path_prefix"]
payload["simnet_af_path_suffix"] = item["simnet_af_path_suffix"]
payload["simnet_af_magic_mode"] = smString(item["simnet_af_magic_mode"])
payload["simnet_af_response_jitter_ms"] = smInt(item["simnet_af_response_jitter_ms"])
payload["simnet_af_handshake_polymorphism"] = smBool(item["simnet_af_handshake_polymorphism"])
payload["simnet_af_settings_jitter"] = smBool(item["simnet_af_settings_jitter"])
payload["simnet_af_fake_header_injection"] = smBool(item["simnet_af_fake_header_injection"])
}
encoded := encodeProtocolPayload(payload)
if encoded == "" {
continue
}
result = append(result, "simnet://"+encoded+"#"+url.QueryEscape(tag))
}
return result
}
// encodeProtocolPayload url-encodes a payload map and base64-encodes it,
// matching the reference encodeProtocolPayload.
func encodeProtocolPayload(payload map[string]interface{}) string {
values := url.Values{}
for key, value := range payload {
switch v := value.(type) {
case nil:
continue
case string:
if strings.TrimSpace(v) != "" {
values.Set(key, v)
}
case bool:
if v {
values.Set(key, "1")
}
case int:
if v != 0 {
values.Set(key, strconv.Itoa(v))
}
case int32:
if v != 0 {
values.Set(key, strconv.FormatInt(int64(v), 10))
}
case int64:
if v != 0 {
values.Set(key, strconv.FormatInt(v, 10))
}
case []string:
if len(v) > 0 {
values.Set(key, strings.Join(v, ","))
}
case []interface{}:
items := make([]string, 0, len(v))
for _, item := range v {
if s := smString(item); s != "" {
items = append(items, s)
}
}
if len(items) > 0 {
values.Set(key, strings.Join(items, ","))
}
default:
if s := smString(v); s != "" {
values.Set(key, s)
}
}
}
if len(values) == 0 {
return ""
}
return base64.StdEncoding.EncodeToString([]byte(values.Encode()))
}