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:
@@ -160,6 +160,30 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
|||||||
CertMode: protocol.CertMode,
|
CertMode: protocol.CertMode,
|
||||||
CertDNSProvider: protocol.CertDNSProvider,
|
CertDNSProvider: protocol.CertDNSProvider,
|
||||||
CertDNSEnv: protocol.CertDNSEnv,
|
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
@@ -81,10 +81,38 @@ type Proxy struct {
|
|||||||
CertMode string // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string // DNS provider for certificate
|
CertDNSProvider string // DNS provider for certificate
|
||||||
CertDNSEnv string // Environment for DNS provider
|
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 {
|
type User struct {
|
||||||
Password string
|
Password string
|
||||||
|
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
|
||||||
ExpiredAt time.Time
|
ExpiredAt time.Time
|
||||||
Download int64
|
Download int64
|
||||||
Upload int64
|
Upload int64
|
||||||
@@ -104,7 +132,10 @@ type Client struct {
|
|||||||
|
|
||||||
func (c *Client) Build() ([]byte, error) {
|
func (c *Client) Build() ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -1045,6 +1045,51 @@ type (
|
|||||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||||
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
||||||
|
SimnetPsk string `json:"simnet_psk,omitempty"`
|
||||||
|
SimnetKeyID int `json:"simnet_key_id,omitempty"`
|
||||||
|
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||||
|
SimnetPath string `json:"simnet_path,omitempty"`
|
||||||
|
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||||
|
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||||
|
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||||
|
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||||
|
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||||
|
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||||
|
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||||
|
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||||
|
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||||
|
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||||
|
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||||
|
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||||
|
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||||
|
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||||
|
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||||
|
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||||
|
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||||
|
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||||
|
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||||
|
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||||
|
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||||
|
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||||
|
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||||
|
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||||
|
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||||
|
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||||
|
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||||
|
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||||
|
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||||
|
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||||
|
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||||
|
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||||
|
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||||
|
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||||
|
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||||
|
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||||
|
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||||
}
|
}
|
||||||
// reset user subscribe token
|
// reset user subscribe token
|
||||||
ResetUserSubscribeTokenRequest {
|
ResetUserSubscribeTokenRequest {
|
||||||
|
|||||||
@@ -0,0 +1,813 @@
|
|||||||
|
# PPanel Server Simnet 协议接入实施计划
|
||||||
|
|
||||||
|
本文档用于指导在现有自维护后端 `/Users/Apple/code_vpn/vpn/ppanel-server` 中接入 `simnet` 协议。目标不是把 Pro 新版后端整体迁移进来,而是在保留旧系统架构、数据库主链路和现有节点管理模型的前提下,把 `simnet` 做到管理端可配置、OmnXT 节点可拉取、SlagClient 可订阅连接、用户授权和流量统计闭环。
|
||||||
|
|
||||||
|
参考实现来自新版 Pro 后端:`/Users/Apple/Downloads/NPanelPro-pro/NPanel-backend`。
|
||||||
|
|
||||||
|
## 1. 项目背景
|
||||||
|
|
||||||
|
当前旧后端已经有完整的 Server、Node、Subscribe、Traffic、Online User 等链路,协议配置主要保存在 Server 的 `protocols` JSON 字段里,Node 侧用 `protocol + port + address` 描述对外节点。新版 Pro 后端已经加入了 `simnet` 协议字段、管理端接口、节点兼容接口和订阅交付逻辑,但它的整体工程结构和旧仓库不同。
|
||||||
|
|
||||||
|
旧仓库是 Gin/goctl/Gorm 风格,核心入口包括:
|
||||||
|
|
||||||
|
- API 定义:`apis/admin/server.api`、`apis/node/node.api`、`apis/public/subscribe.api`、`apis/types.api`
|
||||||
|
- 生成类型:`internal/types/types.go`
|
||||||
|
- 管理端 Server 逻辑:`internal/logic/admin/server/*`
|
||||||
|
- 节点服务端配置拉取:`internal/logic/server/getServerConfigLogic.go`
|
||||||
|
- 节点用户列表拉取:`internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- 公共订阅节点返回:`internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
- 节点在线与流量上报:`internal/logic/server/pushOnlineUsersLogic.go`、`internal/logic/server/serverPushUserTrafficLogic.go`
|
||||||
|
|
||||||
|
新版 Pro 的关键参考入口包括:
|
||||||
|
|
||||||
|
- Simnet 管理端字段:`api/admin/server/v1/server.proto`
|
||||||
|
- OmnXT 节点兼容接口:`internal/server/http_compat_server.go`
|
||||||
|
- 公共订阅响应:`api/public/subscribe/v1/subscribe.proto`
|
||||||
|
- 公共订阅映射:`internal/service/public/subscribe/subscribe.go`
|
||||||
|
- UA/capability 过滤:`internal/biz/public/subscribe/subscribe.go`
|
||||||
|
- 节点交付数据:`internal/data/delivery_node.go`
|
||||||
|
- 协议模型和默认值:`internal/model/server/protocol.go`
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
1. 在旧后端中完整支持 `simnet` 协议的保存、查询、下发、订阅和统计。
|
||||||
|
2. 继续使用旧系统 Server 的 `protocols` JSON 保存协议配置,不强制拆表保存管理端协议配置。
|
||||||
|
3. 第一版支持当前实际需要的能力:H2、TLS/SNI、AF、HTTPS Fallback。
|
||||||
|
4. Reverse 字段先纳入模型和接口,默认关闭;不在第一版强制上线 Reverse 转发能力。
|
||||||
|
5. 管理端配置、OmnXT 服务端运行配置、SlagClient 客户端订阅配置使用不同 DTO,避免敏感字段误下发。
|
||||||
|
6. 使用 `type + port` 唯一定位一个 Server 内的协议实例,支持同一 Server 未来存在多个协议。
|
||||||
|
7. OmnXT 拉取配置必须校验 `secret_key`。
|
||||||
|
8. Server 级 PSK 不得下发给普通用户。
|
||||||
|
9. 优先设计每用户独立 Simnet Key ID/PSK,使用户隔离、封禁、重置和审计可控。
|
||||||
|
10. SlagClient 订阅响应兼容 `protocols` JSON 和顶层 `simnet_*` 字段。
|
||||||
|
|
||||||
|
### 非目标
|
||||||
|
|
||||||
|
1. 不整体替换旧后端为 Pro 新后端。
|
||||||
|
2. 不一次性迁移 Pro 的全部协议字段、路由系统、完整 delivery node 架构。
|
||||||
|
3. 不第一版实现 OmniFlow 或其他新协议。
|
||||||
|
4. 不改变现有套餐、订单、余额、邀请等业务主链路。
|
||||||
|
5. 不把生产服务器凭据、JWT、节点 SSH 密码写入代码或文档。
|
||||||
|
|
||||||
|
## 3. 总体技术策略
|
||||||
|
|
||||||
|
最科学的迁移方式是“协议纵向切入”,而不是“代码横向搬运”。也就是沿着 `simnet` 从管理端保存到节点运行,再到用户订阅、授权、流量统计的完整链路逐层补齐。
|
||||||
|
|
||||||
|
建议分三段落地:
|
||||||
|
|
||||||
|
1. Server 侧先闭环:管理端能保存 `simnet`,OmnXT 能用 `secret_key` 拉到运行配置。
|
||||||
|
2. User 侧再闭环:每个用户生成独立凭据,OmnXT 用户列表和 SlagClient 订阅使用同一套凭据。
|
||||||
|
3. 运维侧最后闭环:流量、在线、到期、限额、TLS/AF/Fallback、灰度和回滚全部验证。
|
||||||
|
|
||||||
|
核心原则:
|
||||||
|
|
||||||
|
- 旧架构优先:沿用 goctl API、`internal/types`、现有 logic/model 风格。
|
||||||
|
- DTO 分层:管理端 DTO 可以看到完整配置;节点 DTO 只给 OmnXT 运行需要;订阅 DTO 只给用户连接需要。
|
||||||
|
- 敏感字段隔离:Server PSK、证书 DNS 环境变量、节点密钥不得进入普通用户订阅响应。
|
||||||
|
- 渐进兼容:老协议、老客户端、老节点不受影响。
|
||||||
|
- 可回滚:每个阶段都能通过关闭 `simnet` 协议或恢复旧接口行为回滚。
|
||||||
|
|
||||||
|
## 4. Simnet 数据链路
|
||||||
|
|
||||||
|
完整链路如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Admin UI
|
||||||
|
-> POST /api/v1/admin/server/create or update
|
||||||
|
-> Server.protocols JSON contains type=simnet
|
||||||
|
|
||||||
|
OmnXT Node
|
||||||
|
-> GET /api/v1/server/config?server_id=...&protocol=simnet&secret_key=...
|
||||||
|
-> receives server runtime config, including server-side PSK and TLS/AF/Fallback settings
|
||||||
|
|
||||||
|
OmnXT Node
|
||||||
|
-> GET /api/v1/server/user/list?server_id=...&protocol=simnet&secret_key=...
|
||||||
|
-> receives active user authorization list and per-user simnet credentials
|
||||||
|
|
||||||
|
SlagClient
|
||||||
|
-> GET /api/v1/public/subscribe?token=... with capability headers
|
||||||
|
-> receives node address, port, TLS/SNI, path, AF/Fallback public fields and user credential
|
||||||
|
|
||||||
|
OmnXT Node
|
||||||
|
-> POST traffic / online user report
|
||||||
|
-> backend maps simnet user credential to user subscribe and records traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
`simnet` 的运行配置不能只靠 `server.protocols` 原样下发,因为同一份 JSON 同时包含管理端字段、Server 密钥字段和用户连接字段。必须在每个出口做字段筛选和转换。
|
||||||
|
|
||||||
|
## 5. 阶段 0:建立基线与确认契约
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
确认旧后端、OmnXT、SlagClient 对 `simnet` 的最小契约,先把边界钉牢,避免后续实现时字段名、鉴权方式或客户端解析格式反复改。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 从新版 Pro 提取 `simnet` 管理字段、服务端字段、订阅字段的差异表。
|
||||||
|
2. 用当前 OmnXT 安装脚本部署的版本抓取真实请求路径和请求参数。
|
||||||
|
3. 用 SlagClient 抓取订阅请求 header,确认 capability header 名称和版本值。
|
||||||
|
4. 确认 `secret_key` 当前在旧仓库 `internal/middleware/serverMiddleware.go` 或节点接口 handler 中的校验方式。
|
||||||
|
5. 确认 `server_id + protocol` 是否已经足够定位节点运行配置;如果端口也会重复,需要补充 `port` 查询参数。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
本阶段原则上不改业务代码,只新增测试夹具或临时验证脚本。可新增:
|
||||||
|
|
||||||
|
- `tests/simnet/fixtures/`
|
||||||
|
- `docs/simnet-contract.md`,如需要更细的契约文档
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 需要可运行的旧后端本地环境或测试库。
|
||||||
|
- 需要 OmnXT 当前版本真实请求样本。
|
||||||
|
- 需要 SlagClient 当前版本订阅响应解析规则。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 明确 OmnXT 配置接口路径、方法、请求参数和响应字段。
|
||||||
|
2. 明确 SlagClient 识别 `simnet` 的字段格式。
|
||||||
|
3. 明确 capability header 优先级:先 capability header,再 User-Agent 兜底。
|
||||||
|
4. 明确 `type + port` 是协议实例唯一键。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
本阶段不涉及生产行为,无需业务回滚。
|
||||||
|
|
||||||
|
## 6. 阶段 1:协议模型与参数校验
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让旧后端的 `Protocol` 类型可以完整表达第一版 `simnet` 配置,并在创建/更新 Server 时有默认值和校验。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 在 `apis/types.api` 的 `Protocol` 结构加入 `simnet` 字段。
|
||||||
|
2. 重新生成 `internal/types/types.go`。
|
||||||
|
3. 在 `internal/model/node` 中的协议模型加入同名 JSON 字段,保证 Server 的 `protocols` JSON 能完整 marshal/unmarshal。
|
||||||
|
4. 新增 `simnet` 默认值函数,例如 `ApplySimnetDefaults`。
|
||||||
|
5. 新增 `simnet` 参数校验函数,例如 `ValidateSimnetProtocol`。
|
||||||
|
6. 校验 `type + port` 唯一,避免同一 Server 下出现两个 `simnet:443`。
|
||||||
|
7. 限制第一版允许值:`simnet_carrier=h2`、`security=tls|none`,生产建议默认 `tls`。
|
||||||
|
8. 校验 path 必须以 `/` 开头,fallback host 非空时端口必须在 1-65535。
|
||||||
|
9. 校验 `simnet_psk` 最小长度和字符集;自动生成时使用安全随机。
|
||||||
|
|
||||||
|
### 字段范围
|
||||||
|
|
||||||
|
核心字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_psk
|
||||||
|
simnet_key_id
|
||||||
|
simnet_ticket_id
|
||||||
|
simnet_path
|
||||||
|
simnet_carrier
|
||||||
|
```
|
||||||
|
|
||||||
|
TLS 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
security
|
||||||
|
sni
|
||||||
|
allow_insecure
|
||||||
|
cert_mode
|
||||||
|
cert_dns_provider
|
||||||
|
cert_dns_env
|
||||||
|
```
|
||||||
|
|
||||||
|
AF 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_af_enabled
|
||||||
|
simnet_af_path_mode
|
||||||
|
simnet_af_path_prefix
|
||||||
|
simnet_af_path_suffix
|
||||||
|
simnet_af_magic_mode
|
||||||
|
simnet_af_response_jitter_ms
|
||||||
|
simnet_af_handshake_polymorphism
|
||||||
|
simnet_af_settings_jitter
|
||||||
|
simnet_af_fake_header_injection
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_fallback_enabled
|
||||||
|
simnet_fallback_target_scheme
|
||||||
|
simnet_fallback_target_host
|
||||||
|
simnet_fallback_target_port
|
||||||
|
simnet_fallback_host_header
|
||||||
|
simnet_fallback_tls_sni
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
simnet_reverse_enabled
|
||||||
|
simnet_reverse_listen_addr
|
||||||
|
simnet_reverse_listen_port
|
||||||
|
simnet_reverse_target_host
|
||||||
|
simnet_reverse_target_port
|
||||||
|
```
|
||||||
|
|
||||||
|
### 默认值
|
||||||
|
|
||||||
|
建议默认值如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
port: 443
|
||||||
|
simnet_path: /simnet/session
|
||||||
|
simnet_carrier: h2
|
||||||
|
security: tls
|
||||||
|
allow_insecure: false
|
||||||
|
simnet_af_path_mode: api
|
||||||
|
simnet_af_magic_mode: derived
|
||||||
|
simnet_af_response_jitter_ms: 1
|
||||||
|
simnet_reverse_enabled: false
|
||||||
|
simnet_reverse_listen_addr: 127.0.0.1
|
||||||
|
simnet_fallback_enabled: true
|
||||||
|
simnet_fallback_target_scheme: https
|
||||||
|
simnet_fallback_target_port: 443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/types.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/model/node/*` 或实际定义 `node.Protocol` 的文件
|
||||||
|
- `internal/logic/admin/server/createServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateServerLogic.go`
|
||||||
|
- 可新增 `internal/logic/admin/server/protocol_simnet.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 0 的字段契约。
|
||||||
|
- goctl 代码生成命令可用。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 管理端提交 `type=simnet` 时,Server 可以保存完整 JSON。
|
||||||
|
2. 未传默认字段时自动补齐默认值。
|
||||||
|
3. 非法 path、非法 port、重复 `type + port` 会被拒绝。
|
||||||
|
4. 旧协议保存和返回不变。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
关闭管理端提交 `simnet` 的入口校验;或恢复 `apis/types.api` 和生成类型,旧协议数据仍可继续工作。
|
||||||
|
|
||||||
|
## 7. 阶段 2:管理端 Server 接口
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让管理端 Server 创建、更新、查询能完整展示和编辑 `simnet`,并保持 Node 更新接口与 Server 协议配置一致。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 更新 `CreateServerRequest`、`UpdateServerRequest`、`FilterServerListResponse`、`GetServerProtocolsResponse` 中的协议字段。
|
||||||
|
2. 在 create/update Server 时对每个 protocol 先做 normalize,再落库。
|
||||||
|
3. 在 filter/list/detail 接口中返回规范化后的 `simnet` 字段。
|
||||||
|
4. 检查 `CreateNodeRequest`、`UpdateNodeRequest` 是否允许 `protocol=simnet`。
|
||||||
|
5. Node 端 `node_type=front` 的创建/更新要允许 `simnet`,并校验其 `port` 与 Server 里的 `simnet` 协议端口一致。
|
||||||
|
6. 如果管理端前端需要协议选项,`GetServerProtocols` 要返回 `simnet`,并带默认字段方便 UI 填充。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/admin/server.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/admin/server/createServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||||
|
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||||
|
- `internal/logic/admin/server/createNodeLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateNodeLogic.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 协议模型已经可表达 `simnet`。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 管理端能创建一个 Server,包含 `simnet:443`。
|
||||||
|
2. 管理端能更新 `simnet_path`、`sni`、AF 和 fallback 字段。
|
||||||
|
3. 管理端节点列表显示 `HK simnet` 这类节点时,协议类型不丢失。
|
||||||
|
4. `GetServerProtocols` 返回的 `protocols` JSON 与数据库一致且字段完整。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
从管理端把 `simnet` 协议 disabled,保留数据但不对节点下发;或回滚 Server 相关 API 和 logic。
|
||||||
|
|
||||||
|
## 8. 阶段 3:OmnXT 服务端配置下发
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 OmnXT 节点通过旧后端节点 API 拉到可运行的 `simnet` 服务端配置。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 检查 `apis/node/node.api` 中 `GetServerConfigRequest` 是否有 `secret_key`、`server_id`、`protocol`。
|
||||||
|
2. 在 `GetServerConfigLogic` 中加入 `protocol=simnet` 分支。
|
||||||
|
3. 根据 `server_id + protocol + port` 找到启用的 `simnet` 协议配置。
|
||||||
|
4. 验证 `secret_key`,失败时返回明确错误,并记录来源 IP 和 server_id。
|
||||||
|
5. 构造 OmnXT 服务端运行 DTO,包含 Server 运行需要的 PSK、path、carrier、TLS、SNI、AF、fallback、reverse 默认关闭字段。
|
||||||
|
6. 不把管理端专用字段、无关协议字段原样塞给 OmnXT。
|
||||||
|
7. 缓存 key 要包含 `server_id + protocol + port`,避免同端口多协议污染缓存。
|
||||||
|
8. OmnXT 配置变更后要能通过更新 Server 或清理缓存生效。
|
||||||
|
|
||||||
|
### 服务端 DTO 建议
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocol": "simnet",
|
||||||
|
"port": 443,
|
||||||
|
"listen": ":443",
|
||||||
|
"simnet_psk": "server-side-secret",
|
||||||
|
"simnet_path": "/simnet/session",
|
||||||
|
"simnet_carrier": "h2",
|
||||||
|
"security": "tls",
|
||||||
|
"sni": "example.com",
|
||||||
|
"allow_insecure": false,
|
||||||
|
"simnet_af_enabled": true,
|
||||||
|
"simnet_fallback_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/server/getServerConfigLogic.go`
|
||||||
|
- `internal/logic/server/constant.go`
|
||||||
|
- `internal/middleware/serverMiddleware.go`
|
||||||
|
- 可新增 `internal/logic/server/simnet_config.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 和阶段 2。
|
||||||
|
- OmnXT 实际接口字段确认完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. `secret_key` 正确时,OmnXT 能拉到 `simnet` 服务端配置。
|
||||||
|
2. `secret_key` 错误时,请求被拒绝。
|
||||||
|
3. 修改管理端 `simnet_path` 后,OmnXT 重启或刷新能拿到新 path。
|
||||||
|
4. Server PSK 只出现在 OmnXT 服务端配置中,不出现在普通用户订阅中。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
关闭 `simnet.enable` 或回滚 `GetServerConfigLogic` 的 `simnet` 分支;旧协议节点不受影响。
|
||||||
|
|
||||||
|
## 9. 阶段 4:用户级 Simnet 凭据
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
为每个有效用户订阅生成独立 `simnet` 凭据,避免所有用户共享 Server PSK,支持单用户封禁、重置和流量归属。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 新增用户级凭据模型,建议按 `user_subscribe_id + server_id + protocol + port` 维度唯一。
|
||||||
|
2. 字段建议包括:`id`、`user_id`、`user_subscribe_id`、`server_id`、`protocol`、`port`、`key_id`、`psk`、`ticket_id`、`enabled`、`created_at`、`updated_at`、`rotated_at`。
|
||||||
|
3. 添加数据库 migration,并在初始化兼容逻辑中保证表存在。
|
||||||
|
4. 用户第一次订阅或节点第一次拉用户列表时懒生成凭据。
|
||||||
|
5. 支持管理员重置某个用户订阅 token 时同步重置 `simnet` 凭据,避免旧凭据继续可用。
|
||||||
|
6. 凭据生成使用加密安全随机;`key_id` 可用递增 id 或稳定 hash,但必须避免全局冲突。
|
||||||
|
7. 保留 `ticket_id` 字段,第一版可为空或由 OmnXT 需要时生成。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `internal/model/user/*` 或新增 `internal/model/simnet/*`
|
||||||
|
- `initialize/migrate/*`
|
||||||
|
- `initialize/schema_compat.go`
|
||||||
|
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
- `internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- 用户订阅 token 重置逻辑:`internal/logic/admin/user/resetUserSubscribeTokenHandler.go` 对应 logic
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 0 确认 OmnXT 和 SlagClient 需要的用户凭据格式。
|
||||||
|
- 阶段 1 的协议模型完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 同一用户同一节点多次订阅拿到稳定凭据。
|
||||||
|
2. 不同用户拿到不同凭据。
|
||||||
|
3. 重置用户订阅 token 后旧凭据失效,新凭据生效。
|
||||||
|
4. 凭据表有唯一约束,重复生成不会产生两条有效凭据。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
可以停止向 OmnXT 下发 `simnet` 用户授权,并禁用 `simnet` 节点。数据库表可保留,不影响旧协议。
|
||||||
|
|
||||||
|
## 10. 阶段 5:OmnXT 用户授权同步
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 OmnXT 拉取用户列表时获得 `simnet` 可认证用户,并且用户到期、限额、禁用、套餐节点组变化后同步生效。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 在 `GetServerUserListLogic` 中加入 `simnet` 用户映射。
|
||||||
|
2. 沿用旧系统的有效用户筛选条件:订阅有效、未到期、流量未超限、用户未禁用、节点组有权限。
|
||||||
|
3. 对 `simnet` 用户返回 `user_id`、`subscribe_id`、`uuid`、`key_id`、`psk`、`ticket_id`、限速字段。
|
||||||
|
4. OmnXT 请求 `protocol=simnet` 时,只返回有 `simnet` 权限的用户。
|
||||||
|
5. 缓存 key 加入 `protocol + port`,用户订阅变更、流量变更、节点组变更时能失效。
|
||||||
|
6. 对 `hysteria2` 等旧兼容映射不做破坏;`normalizeServerUserListProtocol` 仅新增 `simnet` 透传。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- `internal/logic/server/constant.go`
|
||||||
|
- 用户订阅、节点组、流量相关 model/service
|
||||||
|
- 可新增 `internal/logic/server/simnet_user.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 4 用户级凭据。
|
||||||
|
- 现有用户有效性判断需要梳理清楚。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. OmnXT 拉用户列表时能看到有效用户的 `simnet` 凭据。
|
||||||
|
2. 用户到期、禁用或流量超限后,从 OmnXT 用户列表消失。
|
||||||
|
3. 套餐节点组取消该节点后,从 OmnXT 用户列表消失。
|
||||||
|
4. 老协议用户列表响应不变。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
保留凭据表,但关闭 `GetServerUserListLogic` 的 `simnet` 分支或禁用节点。
|
||||||
|
|
||||||
|
## 11. 阶段 6:公共订阅与 SlagClient
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 SlagClient 冷启动、重启、重新订阅时都能拿到完整 `simnet` 节点,并正确构造连接。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 在 `apis/public/subscribe.api` 的 `UserSubscribeNodeInfo` 加入用户连接需要的顶层 `simnet_*` 字段。
|
||||||
|
2. 保留 `protocols` JSON,确保 SlagClient 旧解析路径仍可读取。
|
||||||
|
3. 在 `QueryUserSubscribeNodeListLogic` 中解析 Server 的 `protocols` JSON,并把匹配 `node.protocol + node.port` 的 `simnet` 配置映射到订阅响应。
|
||||||
|
4. 订阅响应只下发用户级 `simnet_key_id`、用户级 `simnet_psk`、可公开 path/carrier/TLS/SNI/AF/Fallback 字段。
|
||||||
|
5. 不下发 Server 级 `simnet_psk`、DNS provider env、管理端密钥字段。
|
||||||
|
6. 新增 capability header 判断,例如 `X-Client-Capabilities: simnet` 或当前 SlagClient 实际 header。
|
||||||
|
7. 如果没有 capability header,则使用 User-Agent 作为兼容兜底;不应单纯依赖 UA。
|
||||||
|
8. 对不支持 `simnet` 的客户端隐藏 `simnet` 节点,避免客户端崩溃或展示不可用节点。
|
||||||
|
9. 如果 SlagClient 同时支持 `protocols` JSON 和顶层字段,优先让顶层字段完整,`protocols` 作为兼容冗余。
|
||||||
|
|
||||||
|
### 订阅 DTO 建议
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HK simnet",
|
||||||
|
"protocol": "simnet",
|
||||||
|
"port": 443,
|
||||||
|
"address": "node.example.com",
|
||||||
|
"sni": "net.example.com",
|
||||||
|
"simnet_key_id": 10001,
|
||||||
|
"simnet_psk": "user-side-secret",
|
||||||
|
"simnet_ticket_id": "",
|
||||||
|
"simnet_path": "/simnet/session",
|
||||||
|
"simnet_carrier": "h2",
|
||||||
|
"security": "tls",
|
||||||
|
"allow_insecure": false,
|
||||||
|
"simnet_af_enabled": true,
|
||||||
|
"simnet_af_path_mode": "api",
|
||||||
|
"simnet_af_magic_mode": "derived",
|
||||||
|
"simnet_fallback_enabled": true,
|
||||||
|
"simnet_fallback_target_scheme": "https",
|
||||||
|
"simnet_fallback_target_host": "www.example.com",
|
||||||
|
"simnet_fallback_target_port": 443
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/public/subscribe.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
- `internal/logic/common/subscriptionTrace.go`,如有订阅 UA 或设备记录
|
||||||
|
- 可新增 `internal/logic/public/subscribe/simnet_mapper.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 4 用户级凭据。
|
||||||
|
- SlagClient capability header 契约确认。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. SlagClient 冷启动订阅后能看到 `simnet` 节点。
|
||||||
|
2. SlagClient 重启后仍能从订阅恢复连接配置。
|
||||||
|
3. 不支持 `simnet` 的客户端订阅不返回 `simnet` 节点。
|
||||||
|
4. 普通用户订阅响应不包含 Server 级 PSK。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
订阅侧隐藏 `simnet` 节点或关闭 capability 开关;旧协议订阅不受影响。
|
||||||
|
|
||||||
|
## 12. 阶段 7:流量和在线用户映射
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
让 OmnXT 上报的 `simnet` 在线用户和流量能正确归属到用户订阅,并触发旧系统现有的限额、日志、后台统计。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 确认 OmnXT 上报用户标识是 `uuid`、`key_id`、`user_id` 还是其他字段。
|
||||||
|
2. 如果 OmnXT 上报 `key_id`,后端通过用户级凭据表反查 `user_subscribe_id` 和 `user_id`。
|
||||||
|
3. 如果 OmnXT 上报 `uuid`,需要确认 `uuid` 与 `simnet` 凭据绑定关系,不允许跨用户伪造。
|
||||||
|
4. 在 `serverPushUserTrafficLogic` 中加入 `simnet` 标识解析。
|
||||||
|
5. 在 `pushOnlineUsersLogic` 中加入 `simnet` 在线用户映射。
|
||||||
|
6. 更新后台节点在线数统计,确保 `simnet:443` 与其他协议隔离。
|
||||||
|
7. 失败上报要记录协议、server_id、port、用户标识和错误原因,方便排查。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||||
|
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||||
|
- `internal/model/traffic/*`
|
||||||
|
- `internal/model/node/*`
|
||||||
|
- 凭据表 model
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 4 用户级凭据。
|
||||||
|
- OmnXT 上报格式确认。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. `simnet` 连接产生流量后,用户已用流量增加。
|
||||||
|
2. 节点后台能看到 `simnet` 在线人数。
|
||||||
|
3. 用户超限后 OmnXT 用户列表不再包含该用户。
|
||||||
|
4. 旧协议流量统计不受影响。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
禁用 `simnet` 流量上报分支或关闭 `simnet` 节点;旧协议统计不受影响。
|
||||||
|
|
||||||
|
## 13. 阶段 8:TLS、AF 与 Fallback
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把当前实际部署需要的 TLS/SNI、AF 和 HTTPS Fallback 做到可配置、可验证、可运维。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. TLS:支持 `security=tls`、`sni`、`allow_insecure=false`。
|
||||||
|
2. 证书模式:第一版支持 `cert_mode=http`;DNS provider 字段先保留,不在普通订阅下发。
|
||||||
|
3. AF:支持 `simnet_af_enabled`、`path_mode=api`、`magic_mode=derived`、`response_jitter_ms`。
|
||||||
|
4. Fallback:支持 fallback scheme、host、port、host header、TLS SNI。
|
||||||
|
5. Reverse:字段保存和下发给 OmnXT,但默认关闭;如果开启必须要求 target host/port 完整。
|
||||||
|
6. 添加配置快照日志,OmnXT 拉取时打印非敏感字段,便于确认线上配置是否生效。
|
||||||
|
7. 对真实节点做 `443` 端口监听、证书申请、fallback 站点访问验证。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `internal/logic/admin/server/protocol_simnet.go`
|
||||||
|
- `internal/logic/server/simnet_config.go`
|
||||||
|
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||||
|
- `etc/ppanel.yaml`,如需要新增全局开关
|
||||||
|
- 节点部署文档或运维脚本,视 OmnXT 实际需求决定
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 3 OmnXT 配置下发。
|
||||||
|
- 节点服务器域名、证书、端口和 fallback 目标准备完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. OmnXT 能在 `443` 启动 `simnet` H2 TLS。
|
||||||
|
2. SNI 与证书匹配。
|
||||||
|
3. AF 开启后 SlagClient 仍可连接。
|
||||||
|
4. Fallback 目标在非协议请求时可访问。
|
||||||
|
5. OmnXT 重启后配置仍然生效。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
关闭 AF 或 fallback;必要时把 `simnet.enable=false`,保留旧协议节点承载用户。
|
||||||
|
|
||||||
|
## 14. 阶段 9:自动化测试
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
用测试保护 `simnet` 的关键契约,减少后续修改协议字段时再次出现“面板有配置、节点拿不到、客户端不识别”的问题。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 协议模型测试:默认值、校验、marshal/unmarshal。
|
||||||
|
2. 管理端测试:create/update Server 保存 `simnet` 字段完整。
|
||||||
|
3. 节点配置测试:`secret_key` 正确/错误、`simnet` DTO 字段筛选。
|
||||||
|
4. 用户凭据测试:生成稳定性、用户隔离、重置失效。
|
||||||
|
5. 订阅测试:capability header 支持时返回 `simnet`;不支持时隐藏。
|
||||||
|
6. 敏感字段测试:普通订阅中不得出现 Server PSK、DNS env。
|
||||||
|
7. 流量测试:OmnXT 上报 `key_id` 后可归属用户。
|
||||||
|
8. 回归测试:现有 vless、trojan、hysteria2、shadowsocks 订阅不变。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `tests/acceptance/*`
|
||||||
|
- `internal/logic/admin/server/*_test.go`
|
||||||
|
- `internal/logic/server/*_test.go`
|
||||||
|
- `internal/logic/public/subscribe/*_test.go`
|
||||||
|
- `internal/model/simnet/*_test.go`
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 到阶段 7 基本实现完成。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. `go test ./...` 通过,或项目当前可执行测试集全部通过。
|
||||||
|
2. 新增测试能覆盖 Server、OmnXT、SlagClient、Traffic 四条主链路。
|
||||||
|
3. 任意敏感字段泄露测试失败时,CI 阻断。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
测试本身不影响生产;如果某阶段实现回滚,相应测试应标记待实现或一并回滚。
|
||||||
|
|
||||||
|
## 15. 阶段 10:灰度发布与回滚
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
把 `simnet` 以可控方式上线,先让一个节点和少量测试用户跑通,再扩大范围。
|
||||||
|
|
||||||
|
### 具体任务
|
||||||
|
|
||||||
|
1. 增加全局或配置级开关:`simnet_enabled`。
|
||||||
|
2. 管理端先创建一个独立测试 Server 和一个 `simnet` front node。
|
||||||
|
3. 只给测试套餐或测试节点组分配该节点。
|
||||||
|
4. 部署 OmnXT,确认能拉配置、拉用户、启动监听。
|
||||||
|
5. 用测试用户订阅 SlagClient,验证冷启动、重启、切换网络、重拉订阅。
|
||||||
|
6. 观察在线用户、流量上报、错误日志、证书续期和 fallback 访问。
|
||||||
|
7. 稳定后把节点加入正式套餐节点组。
|
||||||
|
8. 保留旧协议节点作为回退路径,不把全部用户一次性切到 `simnet`。
|
||||||
|
|
||||||
|
### 预计修改位置
|
||||||
|
|
||||||
|
- `etc/ppanel.yaml`,如需要全局开关
|
||||||
|
- `internal/config/config.go`
|
||||||
|
- `internal/svc/serviceContext.go`
|
||||||
|
- 运维部署文档
|
||||||
|
|
||||||
|
### 依赖关系
|
||||||
|
|
||||||
|
- 阶段 1 到阶段 9 完成。
|
||||||
|
- 测试节点服务器、域名、证书、OmnXT 可用。
|
||||||
|
|
||||||
|
### 验收条件
|
||||||
|
|
||||||
|
1. 测试用户能稳定连接 `simnet`。
|
||||||
|
2. SlagClient 重启后无需人工操作即可恢复。
|
||||||
|
3. OmnXT 重启后能自动拉配置和用户授权。
|
||||||
|
4. 管理端能看到在线和流量。
|
||||||
|
5. 关闭 `simnet` 后用户可回退到旧协议节点。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
1. 管理端将 `simnet` 协议 `enable=false`。
|
||||||
|
2. 从套餐节点组移除 `simnet` 节点。
|
||||||
|
3. OmnXT 停止 `simnet` inbound。
|
||||||
|
4. 回滚后端到上一版本。
|
||||||
|
5. 保留凭据表和字段,后续排查后可再次启用。
|
||||||
|
|
||||||
|
## 16. 文件改动范围
|
||||||
|
|
||||||
|
预计完整生产可用版本会影响 27-45 个业务/配置文件、12-20 个测试文件,新增约 3,000-6,000 行代码和测试。实际数量取决于 goctl 生成文件体积、现有 model 组织方式和 OmnXT/SlagClient 契约是否稳定。
|
||||||
|
|
||||||
|
### 必改范围
|
||||||
|
|
||||||
|
- `apis/types.api`
|
||||||
|
- `apis/admin/server.api`
|
||||||
|
- `apis/node/node.api`
|
||||||
|
- `apis/public/subscribe.api`
|
||||||
|
- `internal/types/types.go`
|
||||||
|
- `internal/model/node/*`
|
||||||
|
- `internal/logic/admin/server/createServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/updateServerLogic.go`
|
||||||
|
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||||
|
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||||
|
- `internal/logic/server/getServerConfigLogic.go`
|
||||||
|
- `internal/logic/server/getServerUserListLogic.go`
|
||||||
|
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||||
|
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||||
|
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||||
|
|
||||||
|
### 可能新增范围
|
||||||
|
|
||||||
|
- `internal/model/simnet/*`
|
||||||
|
- `internal/logic/admin/server/protocol_simnet.go`
|
||||||
|
- `internal/logic/server/simnet_config.go`
|
||||||
|
- `internal/logic/server/simnet_user.go`
|
||||||
|
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||||
|
- `initialize/migrate/*simnet*`
|
||||||
|
- `tests/simnet/*`
|
||||||
|
- `docs/simnet-contract.md`
|
||||||
|
|
||||||
|
### 前端联动范围
|
||||||
|
|
||||||
|
如果管理端前端也要同步配置,需要在前端仓库补齐:
|
||||||
|
|
||||||
|
- Server 创建/编辑表单的 `simnet` 协议字段
|
||||||
|
- 协议默认值填充
|
||||||
|
- 字段校验提示
|
||||||
|
- Node 创建/更新时允许 `protocol=simnet`
|
||||||
|
- 隐藏 Server PSK 的展示或复制入口
|
||||||
|
|
||||||
|
## 17. 提交拆分
|
||||||
|
|
||||||
|
建议按以下提交拆分,方便 review 和回滚:
|
||||||
|
|
||||||
|
1. `simnet: add protocol model fields and validation`
|
||||||
|
2. `simnet: support admin server create/update/list`
|
||||||
|
3. `simnet: expose server runtime config for OmnXT`
|
||||||
|
4. `simnet: add per-user credentials`
|
||||||
|
5. `simnet: sync OmnXT user authorization`
|
||||||
|
6. `simnet: expose public subscribe fields for SlagClient`
|
||||||
|
7. `simnet: map traffic and online reports`
|
||||||
|
8. `simnet: add tls af fallback handling`
|
||||||
|
9. `simnet: add tests and rollout switch`
|
||||||
|
|
||||||
|
每个提交都应该能单独说明行为变化,并尽量避免把 goctl 生成文件和手写逻辑混在一个巨大提交里。如果生成文件不可避免较大,提交说明中要明确哪些是生成结果。
|
||||||
|
|
||||||
|
## 18. 验收标准
|
||||||
|
|
||||||
|
最终验收必须覆盖下面场景:
|
||||||
|
|
||||||
|
1. 管理端能创建 Server,协议为 `simnet`,端口 `443`,TLS/SNI、AF、Fallback 字段保存完整。
|
||||||
|
2. 管理端能创建或更新 Node,`protocol=simnet`,`address` 指向实际节点服务器。
|
||||||
|
3. OmnXT 使用正确 `secret_key` 能拉取 `simnet` 服务端运行配置。
|
||||||
|
4. OmnXT 使用错误 `secret_key` 被拒绝。
|
||||||
|
5. OmnXT 重启后自动恢复 `simnet` inbound。
|
||||||
|
6. 有效用户能通过 OmnXT 用户列表获得授权。
|
||||||
|
7. 不同用户的 `simnet_key_id` 或 `simnet_psk` 不相同。
|
||||||
|
8. 用户禁用、到期或流量超限后,OmnXT 用户列表移除该用户。
|
||||||
|
9. SlagClient 冷启动能通过订阅拿到 `simnet` 节点并连接。
|
||||||
|
10. SlagClient 重启后不丢失协议配置。
|
||||||
|
11. 不支持 `simnet` 的客户端订阅不会收到 `simnet` 节点。
|
||||||
|
12. 普通用户订阅响应不泄露 Server PSK、DNS provider env、节点 `secret_key`。
|
||||||
|
13. `simnet` 连接产生流量后,用户流量、节点流量、后台日志同步更新。
|
||||||
|
14. 关闭 `simnet` 后,旧协议订阅、节点运行和流量统计不受影响。
|
||||||
|
15. `go test ./...` 或项目当前有效测试集通过。
|
||||||
|
|
||||||
|
## 19. 风险清单
|
||||||
|
|
||||||
|
| 风险 | 影响 | 控制方式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Server PSK 被下发给普通用户 | 所有用户共享密钥,泄露后整节点风险扩大 | DTO 分层,订阅敏感字段测试阻断 |
|
||||||
|
| OmnXT 和后端字段名不一致 | 节点启动失败或配置不生效 | 阶段 0 固化契约,用真实 OmnXT 请求回放测试 |
|
||||||
|
| SlagClient 只读顶层字段或只读 protocols JSON | 客户端拿到节点但无法连接 | 双格式兼容,顶层字段和 protocols 都保持可读 |
|
||||||
|
| 单用户凭据缺失 | 无法隔离用户,封禁和流量归属困难 | 阶段 4 必须先做凭据表,不走全员共享 PSK |
|
||||||
|
| capability 判断不准确 | 老客户端看到不可用节点 | capability header 优先,UA 只兜底,默认隐藏不支持客户端 |
|
||||||
|
| 缓存 key 未包含 port | 多协议或同协议多端口串配置 | cache key 包含 `server_id + protocol + port` |
|
||||||
|
| 流量上报标识不明确 | 用户流量无法入账或串账 | 与 OmnXT 明确上报 `key_id`,后端反查凭据表 |
|
||||||
|
| TLS/证书/fallback 运维失败 | 节点 443 无法正常服务 | 灰度节点先跑,保留旧协议回退 |
|
||||||
|
| goctl 生成覆盖手写改动 | 代码冲突或字段丢失 | 所有类型先改 api 文件,再生成;手写扩展放独立文件 |
|
||||||
|
|
||||||
|
## 20. 工期估算
|
||||||
|
|
||||||
|
在 OmnXT 和 SlagClient 契约清楚、测试环境可用的情况下:
|
||||||
|
|
||||||
|
- 阶段 0:0.5-1 天
|
||||||
|
- 阶段 1-2:1.5-2 天
|
||||||
|
- 阶段 3:1-1.5 天
|
||||||
|
- 阶段 4:1.5-2 天
|
||||||
|
- 阶段 5:1-1.5 天
|
||||||
|
- 阶段 6:1-1.5 天
|
||||||
|
- 阶段 7:1-2 天
|
||||||
|
- 阶段 8:1 天
|
||||||
|
- 阶段 9:2-3 天
|
||||||
|
- 阶段 10:1 天
|
||||||
|
|
||||||
|
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
|
||||||
|
|
||||||
|
## 21. 推荐执行顺序
|
||||||
|
|
||||||
|
第一周先完成最小闭环:
|
||||||
|
|
||||||
|
1. 阶段 0:确认契约。
|
||||||
|
2. 阶段 1:协议模型与校验。
|
||||||
|
3. 阶段 2:管理端保存和查询。
|
||||||
|
4. 阶段 3:OmnXT 配置下发。
|
||||||
|
|
||||||
|
第二周完成用户链路:
|
||||||
|
|
||||||
|
1. 阶段 4:用户级凭据。
|
||||||
|
2. 阶段 5:OmnXT 用户授权。
|
||||||
|
3. 阶段 6:SlagClient 订阅。
|
||||||
|
4. 阶段 7:流量和在线用户映射。
|
||||||
|
|
||||||
|
最后做生产化:
|
||||||
|
|
||||||
|
1. 阶段 8:TLS、AF、Fallback 运维验证。
|
||||||
|
2. 阶段 9:自动化测试补齐。
|
||||||
|
3. 阶段 10:灰度发布和回滚演练。
|
||||||
|
|
||||||
|
## 22. 当前结论
|
||||||
|
|
||||||
|
最合理的方案是在旧后端内部补齐 `simnet` 的纵向链路,不建议整体迁移 Pro 新后端。这样风险最小,旧业务稳定性最好,也最贴近当前问题:SlagClient 和 OmnXT 需要的是一个一致、完整、不会泄露敏感字段的 `simnet` 契约。
|
||||||
|
|
||||||
|
第一版真正必须做的是:协议模型、管理端保存、OmnXT 配置、用户级凭据、OmnXT 授权、SlagClient 订阅、流量归属。只要这七个点闭环,`simnet` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM `subscribe_application` WHERE `id` = 1001 AND `name` = 'OmnXT SimNet';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- OmnXT SimNet subscription application.
|
||||||
|
-- Delivers simnet nodes as base64 "simnet://" protocol links built by the
|
||||||
|
-- adapter template function buildOmnxtProtocolLinks (per-user psk/key_id derived
|
||||||
|
-- from the subscription; server PSK carried for AF derivation). Matched by
|
||||||
|
-- User-Agent containing "omnxt". Mirrors the Pro reference final format
|
||||||
|
-- (migrations 02138 + 02140).
|
||||||
|
INSERT IGNORE INTO `subscribe_application`
|
||||||
|
(`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`)
|
||||||
|
VALUES
|
||||||
|
(1001, 'OmnXT SimNet', '', 'OmnXT SimNet base64 subscription', '', 'OmnXT', 0, '{{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}\n{{- end }}', 'base64', '{}', NOW(3), NOW(3));
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|
||||||
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx)
|
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx, c.GetHeader("User-Agent"))
|
||||||
resp, err := l.QueryUserSubscribeNodeList()
|
resp, err := l.QueryUserSubscribeNodeList()
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ func (l *CreateServerLogic) CreateServer(req *types.CreateServerRequest) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Simnet: apply defaults / normalize (no-op for other protocols)
|
||||||
|
protocol.NormalizeSimnet()
|
||||||
protocols = append(protocols, protocol)
|
protocols = append(protocols, protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ func (l *FilterServerListLogic) FilterServerList(req *types.FilterServerListRequ
|
|||||||
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Normalize simnet defaults on the response (safe: dst is a fresh slice).
|
||||||
|
for i := range dst {
|
||||||
|
dst[i].NormalizeSimnet()
|
||||||
|
}
|
||||||
tool.DeepCopy(&protocols, dst)
|
tool.DeepCopy(&protocols, dst)
|
||||||
server.Protocols = protocols
|
server.Protocols = protocols
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ func (l *GetServerProtocolsLogic) GetServerProtocols(req *types.GetServerProtoco
|
|||||||
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[FilterServerList] UnmarshalProtocols Error: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
// Normalize simnet defaults on the response so the admin UI always sees a
|
||||||
|
// consistent config even for legacy/hand-inserted rows. dst is a fresh slice
|
||||||
|
// (not shared with the DB), so mutating it here is safe.
|
||||||
|
for i := range dst {
|
||||||
|
dst[i].NormalizeSimnet()
|
||||||
|
}
|
||||||
tool.DeepCopy(&protocols, dst)
|
tool.DeepCopy(&protocols, dst)
|
||||||
|
|
||||||
return &types.GetServerProtocolsResponse{
|
return &types.GetServerProtocolsResponse{
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ func (l *UpdateServerLogic) UpdateServer(req *types.UpdateServerRequest) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Simnet: apply defaults / normalize (no-op for other protocols)
|
||||||
|
protocol.NormalizeSimnet()
|
||||||
protocols = append(protocols, protocol)
|
protocols = append(protocols, protocol)
|
||||||
}
|
}
|
||||||
err = data.MarshalProtocols(protocols)
|
err = data.MarshalProtocols(protocols)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/simnet"
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -22,14 +23,16 @@ type QueryUserSubscribeNodeListLogic struct {
|
|||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user subscribe node info
|
// Get user subscribe node info
|
||||||
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryUserSubscribeNodeListLogic {
|
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext, userAgent string) *QueryUserSubscribeNodeListLogic {
|
||||||
return &QueryUserSubscribeNodeListLogic{
|
return &QueryUserSubscribeNodeListLogic{
|
||||||
Logger: logger.WithContext(ctx),
|
Logger: logger.WithContext(ctx),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
svcCtx: svcCtx,
|
svcCtx: svcCtx,
|
||||||
|
userAgent: userAgent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +166,12 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
|||||||
if server == nil {
|
if server == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Hide experimental protocols (simnet) — and their server material in
|
||||||
|
// the raw protocols JSON — from non first-party clients. Mirrors the
|
||||||
|
// Pro reference FilterExperimentalNodesForClient.
|
||||||
|
if simnet.IsExperimentalProtocol(n.Protocol) && !simnet.ClientSupportsExperimental(l.userAgent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
userSubscribeNode := &types.UserSubscribeNodeInfo{
|
userSubscribeNode := &types.UserSubscribeNodeInfo{
|
||||||
Id: n.Id,
|
Id: n.Id,
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
|
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
|
||||||
// TODO: remove in future versions
|
// TODO: remove in future versions
|
||||||
Hysteria2 = "hysteria2"
|
Hysteria2 = "hysteria2"
|
||||||
|
Simnet = "simnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
|
|||||||
@@ -234,6 +234,22 @@ func (l *GetServerConfigLogic) compatible(config node.Protocol) map[string]inter
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case Simnet:
|
||||||
|
// Simnet ships its whole normalized protocol as the node runtime config
|
||||||
|
// (server PSK key_id=0, path, carrier, TLS, AF, fallback, reverse, resource
|
||||||
|
// limits), matching the Pro reference (compat_legacy.go simnet case).
|
||||||
|
config.NormalizeSimnet()
|
||||||
|
// Config snapshot log — non-sensitive fields only (never log the PSK).
|
||||||
|
l.Infow("[GetServerConfig] simnet runtime config",
|
||||||
|
logger.Field("port", config.Port),
|
||||||
|
logger.Field("path", config.SimnetPath),
|
||||||
|
logger.Field("carrier", config.SimnetCarrier),
|
||||||
|
logger.Field("security", config.Security),
|
||||||
|
logger.Field("af_enabled", config.SimnetAfEnabled),
|
||||||
|
logger.Field("fallback_enabled", config.SimnetFallbackEnabled),
|
||||||
|
)
|
||||||
|
result = config
|
||||||
|
|
||||||
}
|
}
|
||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
s, _ := json.Marshal(result)
|
s, _ := json.Marshal(result)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func TestNormalizeServerUserListProtocol(t *testing.T) {
|
|||||||
{"tuic unchanged", "tuic", "tuic"},
|
{"tuic unchanged", "tuic", "tuic"},
|
||||||
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
|
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
|
||||||
{"anytls unchanged", "anytls", "anytls"},
|
{"anytls unchanged", "anytls", "anytls"},
|
||||||
|
{"simnet unchanged", "simnet", "simnet"},
|
||||||
{"empty unchanged", "", ""},
|
{"empty unchanged", "", ""},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package subscribe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
|
"github.com/perfect-panel/server/pkg/simnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// filterExperimentalNodesForClient removes experimental-protocol nodes (simnet)
|
||||||
|
// unless the client UA is a first-party client. Prevents generic clients from
|
||||||
|
// rendering unusable simnet entries and from receiving simnet server material.
|
||||||
|
// Keyword logic is shared via pkg/simnet (mirrors the Pro reference).
|
||||||
|
func filterExperimentalNodesForClient(servers []*node.Node, userAgent string) []*node.Node {
|
||||||
|
if simnet.ClientSupportsExperimental(userAgent) {
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
filtered := make([]*node.Node, 0, len(servers))
|
||||||
|
for _, n := range servers {
|
||||||
|
if n == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if simnet.IsExperimentalProtocol(n.Protocol) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, n)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
@@ -101,6 +101,11 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Experimental protocols (simnet) are only delivered to their own clients/SDK
|
||||||
|
// (UA hits omnxt/slag/slaglab). Hide them from every other client so a
|
||||||
|
// generic template never renders a broken/unusable node. Mirrors the Pro
|
||||||
|
// reference FilterExperimentalNodesForClient.
|
||||||
|
servers = filterExperimentalNodesForClient(servers, userAgent)
|
||||||
a := adapter.NewAdapter(
|
a := adapter.NewAdapter(
|
||||||
targetApp.SubscribeTemplate,
|
targetApp.SubscribeTemplate,
|
||||||
adapter.WithServers(servers),
|
adapter.WithServers(servers),
|
||||||
@@ -109,6 +114,7 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
|||||||
adapter.WithOutputFormat(targetApp.OutputFormat),
|
adapter.WithOutputFormat(targetApp.OutputFormat),
|
||||||
adapter.WithUserInfo(adapter.User{
|
adapter.WithUserInfo(adapter.User{
|
||||||
Password: userSubscribe.UUID,
|
Password: userSubscribe.UUID,
|
||||||
|
SubscribeID: userSubscribe.Id,
|
||||||
ExpiredAt: userSubscribe.ExpireTime,
|
ExpiredAt: userSubscribe.ExpireTime,
|
||||||
Download: userSubscribe.Download,
|
Download: userSubscribe.Download,
|
||||||
Upload: userSubscribe.Upload,
|
Upload: userSubscribe.Upload,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ var AllProtocols = []string{
|
|||||||
"tuic",
|
"tuic",
|
||||||
"hysteria",
|
"hysteria",
|
||||||
"hysteria2",
|
"hysteria2",
|
||||||
|
"simnet",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
|
|||||||
"tuic",
|
"tuic",
|
||||||
"hysteria",
|
"hysteria",
|
||||||
"hysteria2",
|
"hysteria2",
|
||||||
|
"simnet",
|
||||||
}
|
}
|
||||||
set := make(map[string]struct{}, len(AllProtocols))
|
set := make(map[string]struct{}, len(AllProtocols))
|
||||||
for _, p := range AllProtocols {
|
for _, p := range AllProtocols {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package node
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -71,17 +73,20 @@ func (m *Server) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalProtocols Marshal server protocols to json
|
// MarshalProtocols Marshal server protocols to json.
|
||||||
|
// Uniqueness is scoped to type+port (a protocol entry is one listener instance),
|
||||||
|
// which allows the same protocol on multiple ports, e.g. simnet:443 and simnet:8443.
|
||||||
func (m *Server) MarshalProtocols(list []Protocol) error {
|
func (m *Server) MarshalProtocols(list []Protocol) error {
|
||||||
var validate = make(map[string]bool)
|
var validate = make(map[string]bool)
|
||||||
for _, protocol := range list {
|
for _, protocol := range list {
|
||||||
if protocol.Type == "" {
|
if strings.TrimSpace(protocol.Type) == "" {
|
||||||
return errors.New("protocol type is required")
|
return errors.New("protocol type is required")
|
||||||
}
|
}
|
||||||
if _, exists := validate[protocol.Type]; exists {
|
key := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(protocol.Type)), protocol.Port)
|
||||||
return errors.New("duplicate protocol type: " + protocol.Type)
|
if _, exists := validate[key]; exists {
|
||||||
|
return errors.New("duplicate protocol type+port: " + key)
|
||||||
}
|
}
|
||||||
validate[protocol.Type] = true
|
validate[key] = true
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(list)
|
data, err := json.Marshal(list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -153,6 +158,192 @@ type Protocol struct {
|
|||||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||||
CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider
|
CertDNSEnv string `json:"cert_dns_env"` // Environment for DNS provider
|
||||||
|
|
||||||
|
// Simnet protocol fields. Field set and json tags mirror the Pro reference
|
||||||
|
// (NPanel-backend internal/model/server/protocol.go) so OmnXT/SlagClient
|
||||||
|
// see an identical wire contract.
|
||||||
|
SimnetPsk string `json:"simnet_psk,omitempty"` // server-side PSK (key_id=0), never sent to normal users
|
||||||
|
SimnetKeyID int `json:"simnet_key_id,omitempty"` // server key id (0)
|
||||||
|
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||||
|
SimnetPath string `json:"simnet_path,omitempty"`
|
||||||
|
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||||
|
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||||
|
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||||
|
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||||
|
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||||
|
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||||
|
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||||
|
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||||
|
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||||
|
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||||
|
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||||
|
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||||
|
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||||
|
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||||
|
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||||
|
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||||
|
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||||
|
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||||
|
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||||
|
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||||
|
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||||
|
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||||
|
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||||
|
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||||
|
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||||
|
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||||
|
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||||
|
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||||
|
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||||
|
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||||
|
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||||
|
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||||
|
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||||
|
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||||
|
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||||
|
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||||
|
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSimnetInboundMaxStreamsPerSession int = 128
|
||||||
|
defaultSimnetInboundMaxUDPStreamsPerSession int = 64
|
||||||
|
defaultSimnetInboundMaxHandlerTasksPerSession int = 128
|
||||||
|
defaultSimnetStreamEventChannelCapacity int = 256
|
||||||
|
defaultSimnetStreamDataChannelCapacity int = 128
|
||||||
|
defaultSimnetTargetDialTimeoutMs int = 12_000
|
||||||
|
defaultSimnetTargetMaxConcurrentDials int = 256
|
||||||
|
defaultSimnetSessionWindow int = 4 * 1024 * 1024
|
||||||
|
defaultSimnetMaxConcurrentStreams int = 100
|
||||||
|
defaultSimnetInitialWindowSize int = 65_535
|
||||||
|
defaultSimnetMaxFrameSize int = 16_384
|
||||||
|
defaultSimnetClientMaxConcurrentStreams int = 32
|
||||||
|
defaultSimnetClientMaxStreamsPerSession int = 512
|
||||||
|
defaultSimnetClientSessionIdleTimeoutSecs int = 90
|
||||||
|
defaultSimnetClientMaxUDPSessions int = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeSimnet applies simnet defaults and clears mutually-exclusive fields.
|
||||||
|
// Ported from the Pro reference (NPanel-backend protocol.go NormalizeSimnet) so
|
||||||
|
// the runtime config matches what OmnXT expects.
|
||||||
|
func (m *Protocol) NormalizeSimnet() {
|
||||||
|
if m == nil || m.Type != "simnet" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Port == 0 {
|
||||||
|
m.Port = 443
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.SimnetPath) == "" {
|
||||||
|
m.SimnetPath = "/simnet/session"
|
||||||
|
}
|
||||||
|
// Carrier: only h2 is supported in v1; coerce empty/grpc to h2 (matches the
|
||||||
|
// Pro reference normalizeSimnetProtocol).
|
||||||
|
if c := strings.TrimSpace(m.SimnetCarrier); c == "" || c == "grpc" {
|
||||||
|
m.SimnetCarrier = "h2"
|
||||||
|
}
|
||||||
|
m.applySimnetResourceDefaults()
|
||||||
|
if !m.SimnetFallbackEnabled || strings.TrimSpace(m.SimnetFallbackTargetHost) == "" {
|
||||||
|
m.SimnetFallbackEnabled = false
|
||||||
|
m.SimnetFallbackTargetScheme = ""
|
||||||
|
m.SimnetFallbackTargetHost = ""
|
||||||
|
m.SimnetFallbackTargetPort = 0
|
||||||
|
m.SimnetFallbackHostHeader = ""
|
||||||
|
m.SimnetFallbackTLSSNI = ""
|
||||||
|
} else {
|
||||||
|
m.SimnetFallbackTargetHost = strings.TrimSpace(m.SimnetFallbackTargetHost)
|
||||||
|
m.SimnetFallbackHostHeader = strings.TrimSpace(m.SimnetFallbackHostHeader)
|
||||||
|
m.SimnetFallbackTLSSNI = strings.TrimSpace(m.SimnetFallbackTLSSNI)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme)) {
|
||||||
|
case "http", "https":
|
||||||
|
m.SimnetFallbackTargetScheme = strings.ToLower(strings.TrimSpace(m.SimnetFallbackTargetScheme))
|
||||||
|
default:
|
||||||
|
m.SimnetFallbackTargetScheme = "https"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.SimnetAfEnabled {
|
||||||
|
m.SimnetAfPathMode = ""
|
||||||
|
m.SimnetAfMagicMode = ""
|
||||||
|
m.SimnetAfPathPrefix = ""
|
||||||
|
m.SimnetAfPathSuffix = ""
|
||||||
|
m.SimnetAfResponseJitterMs = 0
|
||||||
|
m.SimnetAfHandshakePolymorphism = false
|
||||||
|
m.SimnetAfSettingsJitter = false
|
||||||
|
m.SimnetAfFakeHeaderInjection = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.SimnetAfPathMode == "" {
|
||||||
|
m.SimnetAfPathMode = "api"
|
||||||
|
}
|
||||||
|
if m.SimnetAfMagicMode == "" {
|
||||||
|
m.SimnetAfMagicMode = "derived"
|
||||||
|
}
|
||||||
|
if m.SimnetAfResponseJitterMs == 0 {
|
||||||
|
m.SimnetAfResponseJitterMs = 50
|
||||||
|
}
|
||||||
|
if !m.SimnetAfHandshakePolymorphism {
|
||||||
|
m.SimnetAfHandshakePolymorphism = true
|
||||||
|
}
|
||||||
|
if !m.SimnetAfSettingsJitter {
|
||||||
|
m.SimnetAfSettingsJitter = true
|
||||||
|
}
|
||||||
|
if !m.SimnetAfFakeHeaderInjection {
|
||||||
|
m.SimnetAfFakeHeaderInjection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Protocol) applySimnetResourceDefaults() {
|
||||||
|
if m.SimnetInboundMaxStreamsPerSession <= 0 {
|
||||||
|
m.SimnetInboundMaxStreamsPerSession = defaultSimnetInboundMaxStreamsPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetInboundMaxUDPStreamsPerSession <= 0 {
|
||||||
|
m.SimnetInboundMaxUDPStreamsPerSession = defaultSimnetInboundMaxUDPStreamsPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetInboundMaxHandlerTasksPerSession <= 0 {
|
||||||
|
m.SimnetInboundMaxHandlerTasksPerSession = defaultSimnetInboundMaxHandlerTasksPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetStreamEventChannelCapacity <= 0 {
|
||||||
|
m.SimnetStreamEventChannelCapacity = defaultSimnetStreamEventChannelCapacity
|
||||||
|
}
|
||||||
|
if m.SimnetStreamDataChannelCapacity <= 0 {
|
||||||
|
m.SimnetStreamDataChannelCapacity = defaultSimnetStreamDataChannelCapacity
|
||||||
|
}
|
||||||
|
if m.SimnetTargetDialTimeoutMs <= 0 {
|
||||||
|
m.SimnetTargetDialTimeoutMs = defaultSimnetTargetDialTimeoutMs
|
||||||
|
}
|
||||||
|
if m.SimnetTargetMaxConcurrentDials <= 0 {
|
||||||
|
m.SimnetTargetMaxConcurrentDials = defaultSimnetTargetMaxConcurrentDials
|
||||||
|
}
|
||||||
|
if m.SimnetSendWindow <= 0 {
|
||||||
|
m.SimnetSendWindow = defaultSimnetSessionWindow
|
||||||
|
}
|
||||||
|
if m.SimnetRecvWindow <= 0 {
|
||||||
|
m.SimnetRecvWindow = defaultSimnetSessionWindow
|
||||||
|
}
|
||||||
|
if m.SimnetMaxConcurrentStreams <= 0 {
|
||||||
|
m.SimnetMaxConcurrentStreams = defaultSimnetMaxConcurrentStreams
|
||||||
|
}
|
||||||
|
if m.SimnetInitialWindowSize <= 0 {
|
||||||
|
m.SimnetInitialWindowSize = defaultSimnetInitialWindowSize
|
||||||
|
}
|
||||||
|
if m.SimnetMaxFrameSize <= 0 {
|
||||||
|
m.SimnetMaxFrameSize = defaultSimnetMaxFrameSize
|
||||||
|
}
|
||||||
|
if m.SimnetClientMaxConcurrentStreams <= 0 {
|
||||||
|
m.SimnetClientMaxConcurrentStreams = defaultSimnetClientMaxConcurrentStreams
|
||||||
|
}
|
||||||
|
if m.SimnetClientMaxStreamsPerSession <= 0 {
|
||||||
|
m.SimnetClientMaxStreamsPerSession = defaultSimnetClientMaxStreamsPerSession
|
||||||
|
}
|
||||||
|
if m.SimnetClientSessionIdleTimeoutSecs <= 0 {
|
||||||
|
m.SimnetClientSessionIdleTimeoutSecs = defaultSimnetClientSessionIdleTimeoutSecs
|
||||||
|
}
|
||||||
|
if m.SimnetClientMaxUDPSessions <= 0 {
|
||||||
|
m.SimnetClientMaxUDPSessions = defaultSimnetClientMaxUDPSessions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal protocol to json
|
// Marshal protocol to json
|
||||||
|
|||||||
@@ -2349,6 +2349,51 @@ type Protocol struct {
|
|||||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||||
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
||||||
|
SimnetPsk string `json:"simnet_psk,omitempty"`
|
||||||
|
SimnetKeyID int `json:"simnet_key_id,omitempty"`
|
||||||
|
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||||
|
SimnetPath string `json:"simnet_path,omitempty"`
|
||||||
|
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||||
|
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||||
|
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||||
|
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||||
|
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||||
|
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||||
|
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||||
|
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||||
|
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||||
|
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||||
|
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||||
|
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||||
|
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||||
|
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||||
|
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||||
|
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||||
|
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||||
|
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||||
|
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||||
|
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||||
|
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||||
|
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||||
|
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||||
|
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||||
|
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||||
|
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||||
|
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||||
|
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||||
|
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||||
|
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||||
|
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||||
|
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||||
|
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||||
|
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||||
|
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||||
|
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||||
|
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||||
|
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||||
|
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PubilcRegisterConfig struct {
|
type PubilcRegisterConfig struct {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Package simnet holds shared, stateless helpers for the simnet protocol.
|
||||||
|
//
|
||||||
|
// The per-user simnet credentials are DERIVED from the existing user_subscribe
|
||||||
|
// record (id + uuid), not stored in a dedicated table. This mirrors the Pro
|
||||||
|
// reference (NPanel-backend internal/data/delivery_node.go deriveSimnetUserPSK
|
||||||
|
// and internal/biz/public/subscription/template.go key_id derivation), and is
|
||||||
|
// required for wire compatibility with OmnXT / SlagClient:
|
||||||
|
//
|
||||||
|
// - key_id MUST equal the user_subscribe.id (the node's StaticKeyResolver /
|
||||||
|
// assembler identifies the user by this id in the handshake frame header).
|
||||||
|
// - psk is the user's subscription UUID normalized to lowercase 32-hex.
|
||||||
|
package simnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverKeyID is the reserved key id for the server-side PSK (AF derivation).
|
||||||
|
const serverKeyID = 0
|
||||||
|
|
||||||
|
// DeriveKeyID maps a user_subscribe.id to the simnet key_id.
|
||||||
|
// It matches the reference: subscribeID % (2^31-1), avoiding 0 which collides
|
||||||
|
// with the server key id.
|
||||||
|
func DeriveKeyID(subscribeID int64) int {
|
||||||
|
keyID := int(subscribeID % (1<<31 - 1))
|
||||||
|
if keyID == serverKeyID {
|
||||||
|
keyID = 1
|
||||||
|
}
|
||||||
|
return keyID
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveUserPSK normalizes a subscription UUID/password into the per-user PSK.
|
||||||
|
// - canonical UUID -> lowercase hex without dashes (32 chars)
|
||||||
|
// - already 32-char ASCII hex -> lowercased
|
||||||
|
// - otherwise -> hex-encoded bytes of the raw value
|
||||||
|
func DeriveUserPSK(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if isCanonicalUUID(trimmed) {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(trimmed, "-", ""))
|
||||||
|
}
|
||||||
|
if len(trimmed) == 32 && isASCIIHex(trimmed) {
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString([]byte(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCanonicalUUID(value string) bool {
|
||||||
|
if len(value) != 36 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for idx, ch := range value {
|
||||||
|
switch idx {
|
||||||
|
case 8, 13, 18, 23:
|
||||||
|
if ch != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !isASCIIHexRune(ch) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIHex(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ch := range value {
|
||||||
|
if !isASCIIHexRune(ch) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIHexRune(ch rune) bool {
|
||||||
|
return (ch >= '0' && ch <= '9') ||
|
||||||
|
(ch >= 'a' && ch <= 'f') ||
|
||||||
|
(ch >= 'A' && ch <= 'F')
|
||||||
|
}
|
||||||
|
|
||||||
|
// officialClientKeywords are User-Agent substrings of first-party clients/SDKs
|
||||||
|
// allowed to receive experimental protocols. Mirrors the Pro reference
|
||||||
|
// (internal/biz/public/subscribe/subscribe.go officialClientKeywords).
|
||||||
|
var officialClientKeywords = []string{"omnxt", "slag/", "slaglab"}
|
||||||
|
|
||||||
|
// experimentalProtocols must be hidden from non-official clients.
|
||||||
|
var experimentalProtocols = map[string]struct{}{
|
||||||
|
"simnet": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientSupportsExperimental reports whether the client UA is a first-party
|
||||||
|
// client permitted to receive experimental protocols (simnet).
|
||||||
|
func ClientSupportsExperimental(userAgent string) bool {
|
||||||
|
ua := strings.ToLower(userAgent)
|
||||||
|
for _, kw := range officialClientKeywords {
|
||||||
|
if strings.Contains(ua, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExperimentalProtocol reports whether a protocol type is experimental
|
||||||
|
// (hidden from non-official clients).
|
||||||
|
func IsExperimentalProtocol(protocol string) bool {
|
||||||
|
_, ok := experimentalProtocols[strings.ToLower(strings.TrimSpace(protocol))]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user