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
This commit is contained in:
2026-07-27 00:17:02 -07:00
parent 5ef3f2717e
commit 634b5a7bd0
23 changed files with 1728 additions and 107 deletions
+71 -47
View File
@@ -113,53 +113,77 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
proxies = append(
proxies,
Proxy{
Sort: item.Sort,
Name: item.Name,
Server: item.Address,
Port: item.Port,
Type: item.Protocol,
Tags: strings.Split(item.Tags, ","),
Security: protocol.Security,
SNI: protocol.SNI,
AllowInsecure: protocol.AllowInsecure,
Fingerprint: protocol.Fingerprint,
RealityServerAddr: protocol.RealityServerAddr,
RealityServerPort: protocol.RealityServerPort,
RealityPrivateKey: protocol.RealityPrivateKey,
RealityPublicKey: protocol.RealityPublicKey,
RealityShortId: protocol.RealityShortId,
Transport: protocol.Transport,
Host: protocol.Host,
Path: protocol.Path,
ServiceName: protocol.ServiceName,
Method: protocol.Cipher,
ServerKey: protocol.ServerKey,
Flow: protocol.Flow,
HopPorts: protocol.HopPorts,
HopInterval: protocol.HopInterval,
ObfsPassword: protocol.ObfsPassword,
UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps,
DisableSNI: protocol.DisableSNI,
ReduceRtt: protocol.ReduceRtt,
UDPRelayMode: protocol.UDPRelayMode,
CongestionController: protocol.CongestionController,
PaddingScheme: protocol.PaddingScheme,
Multiplex: protocol.Multiplex,
XhttpMode: protocol.XhttpMode,
XhttpExtra: protocol.XhttpExtra,
Encryption: protocol.Encryption,
EncryptionMode: protocol.EncryptionMode,
EncryptionRtt: protocol.EncryptionRtt,
EncryptionTicket: protocol.EncryptionTicket,
EncryptionServerPadding: protocol.EncryptionServerPadding,
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
EncryptionClientPadding: protocol.EncryptionClientPadding,
EncryptionPassword: protocol.EncryptionPassword,
Ratio: protocol.Ratio,
CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider,
CertDNSEnv: protocol.CertDNSEnv,
Sort: item.Sort,
Name: item.Name,
Server: item.Address,
Port: item.Port,
Type: item.Protocol,
Tags: strings.Split(item.Tags, ","),
Security: protocol.Security,
SNI: protocol.SNI,
AllowInsecure: protocol.AllowInsecure,
Fingerprint: protocol.Fingerprint,
RealityServerAddr: protocol.RealityServerAddr,
RealityServerPort: protocol.RealityServerPort,
RealityPrivateKey: protocol.RealityPrivateKey,
RealityPublicKey: protocol.RealityPublicKey,
RealityShortId: protocol.RealityShortId,
Transport: protocol.Transport,
Host: protocol.Host,
Path: protocol.Path,
ServiceName: protocol.ServiceName,
Method: protocol.Cipher,
ServerKey: protocol.ServerKey,
Flow: protocol.Flow,
HopPorts: protocol.HopPorts,
HopInterval: protocol.HopInterval,
ObfsPassword: protocol.ObfsPassword,
UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps,
DisableSNI: protocol.DisableSNI,
ReduceRtt: protocol.ReduceRtt,
UDPRelayMode: protocol.UDPRelayMode,
CongestionController: protocol.CongestionController,
PaddingScheme: protocol.PaddingScheme,
Multiplex: protocol.Multiplex,
XhttpMode: protocol.XhttpMode,
XhttpExtra: protocol.XhttpExtra,
Encryption: protocol.Encryption,
EncryptionMode: protocol.EncryptionMode,
EncryptionRtt: protocol.EncryptionRtt,
EncryptionTicket: protocol.EncryptionTicket,
EncryptionServerPadding: protocol.EncryptionServerPadding,
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
EncryptionClientPadding: protocol.EncryptionClientPadding,
EncryptionPassword: protocol.EncryptionPassword,
Ratio: protocol.Ratio,
CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider,
CertDNSEnv: protocol.CertDNSEnv,
SimnetPsk: protocol.SimnetPsk,
SimnetKeyID: protocol.SimnetKeyID,
SimnetTicketID: protocol.SimnetTicketID,
SimnetPath: protocol.SimnetPath,
SimnetCarrier: protocol.SimnetCarrier,
SimnetAfEnabled: protocol.SimnetAfEnabled,
SimnetAfPathMode: protocol.SimnetAfPathMode,
SimnetAfPathPrefix: protocol.SimnetAfPathPrefix,
SimnetAfPathSuffix: protocol.SimnetAfPathSuffix,
SimnetAfMagicMode: protocol.SimnetAfMagicMode,
SimnetAfResponseJitterMs: protocol.SimnetAfResponseJitterMs,
SimnetAfHandshakePolymorphism: protocol.SimnetAfHandshakePolymorphism,
SimnetAfSettingsJitter: protocol.SimnetAfSettingsJitter,
SimnetAfFakeHeaderInjection: protocol.SimnetAfFakeHeaderInjection,
SimnetFallbackEnabled: protocol.SimnetFallbackEnabled,
SimnetFallbackTargetScheme: protocol.SimnetFallbackTargetScheme,
SimnetFallbackTargetHost: protocol.SimnetFallbackTargetHost,
SimnetFallbackTargetPort: protocol.SimnetFallbackTargetPort,
SimnetFallbackHostHeader: protocol.SimnetFallbackHostHeader,
SimnetFallbackTLSSNI: protocol.SimnetFallbackTLSSNI,
SimnetClientMaxConcurrentStreams: protocol.SimnetClientMaxConcurrentStreams,
SimnetClientMaxStreamsPerSession: protocol.SimnetClientMaxStreamsPerSession,
SimnetClientSessionIdleTimeoutSecs: protocol.SimnetClientSessionIdleTimeoutSecs,
SimnetClientMaxUDPSessions: protocol.SimnetClientMaxUDPSessions,
},
)
}
+32 -1
View File
@@ -81,10 +81,38 @@ type Proxy struct {
CertMode string // Certificate mode, `none``http``dns``self`
CertDNSProvider string // DNS provider for certificate
CertDNSEnv string // Environment for DNS provider
// Simnet Options (server-side config; per-user psk/key_id are derived at
// render time from UserInfo, never stored on the Proxy).
SimnetPsk string // server-side PSK (key_id=0), used for AF derivation
SimnetKeyID int // server key id (0)
SimnetTicketID string
SimnetPath string
SimnetCarrier string
SimnetAfEnabled bool
SimnetAfPathMode string
SimnetAfPathPrefix string
SimnetAfPathSuffix string
SimnetAfMagicMode string
SimnetAfResponseJitterMs int
SimnetAfHandshakePolymorphism bool
SimnetAfSettingsJitter bool
SimnetAfFakeHeaderInjection bool
SimnetFallbackEnabled bool
SimnetFallbackTargetScheme string
SimnetFallbackTargetHost string
SimnetFallbackTargetPort int
SimnetFallbackHostHeader string
SimnetFallbackTLSSNI string
SimnetClientMaxConcurrentStreams int
SimnetClientMaxStreamsPerSession int
SimnetClientSessionIdleTimeoutSecs int
SimnetClientMaxUDPSessions int
}
type User struct {
Password string
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
ExpiredAt time.Time
Download int64
Upload int64
@@ -104,7 +132,10 @@ type Client struct {
func (c *Client) Build() ([]byte, error) {
var buf bytes.Buffer
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
funcMap := sprig.TxtFuncMap()
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
if err != nil {
return nil, err
}
+273
View File
@@ -0,0 +1,273 @@
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()))
}