From 634b5a7bd07b93cc75d4694cd46f297699145440 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Mon, 27 Jul 2026 00:17:02 -0700 Subject: [PATCH] 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 --- adapter/adapter.go | 118 ++- adapter/client.go | 33 +- adapter/simnet.go | 273 ++++++ apis/types.api | 45 + docs/simnet-implementation-plan.md | 813 ++++++++++++++++++ ...2161_subscribe_application_simnet.down.sql | 1 + .../02161_subscribe_application_simnet.up.sql | 10 + .../queryUserSubscribeNodeListHandler.go | 2 +- .../logic/admin/server/createServerLogic.go | 2 + .../admin/server/filterServerListLogic.go | 4 + .../admin/server/getServerProtocolsLogic.go | 6 + .../logic/admin/server/updateServerLogic.go | 2 + .../queryUserSubscribeNodeListLogic.go | 21 +- internal/logic/server/constant.go | 1 + internal/logic/server/getServerConfigLogic.go | 16 + .../server/getServerUserListLogic_test.go | 1 + internal/logic/subscribe/simnet_filter.go | 27 + internal/logic/subscribe/subscribeLogic.go | 6 + internal/model/node/model.go | 1 + internal/model/node/model_test.go | 1 + internal/model/node/server.go | 201 ++++- internal/types/types.go | 139 ++- pkg/simnet/simnet.go | 112 +++ 23 files changed, 1728 insertions(+), 107 deletions(-) create mode 100644 adapter/simnet.go create mode 100644 docs/simnet-implementation-plan.md create mode 100644 initialize/migrate/database/02161_subscribe_application_simnet.down.sql create mode 100644 initialize/migrate/database/02161_subscribe_application_simnet.up.sql create mode 100644 internal/logic/subscribe/simnet_filter.go create mode 100644 pkg/simnet/simnet.go diff --git a/adapter/adapter.go b/adapter/adapter.go index b915bd0..47481c6 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -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, }, ) } diff --git a/adapter/client.go b/adapter/client.go index d267c12..b6f4a82 100644 --- a/adapter/client.go +++ b/adapter/client.go @@ -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 } diff --git a/adapter/simnet.go b/adapter/simnet.go new file mode 100644 index 0000000..ef9fa8a --- /dev/null +++ b/adapter/simnet.go @@ -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())) +} diff --git a/apis/types.api b/apis/types.api index f3bb0b5..1cef7df 100644 --- a/apis/types.api +++ b/apis/types.api @@ -1045,6 +1045,51 @@ type ( CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self` CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate 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 ResetUserSubscribeTokenRequest { diff --git a/docs/simnet-implementation-plan.md b/docs/simnet-implementation-plan.md new file mode 100644 index 0000000..88d4172 --- /dev/null +++ b/docs/simnet-implementation-plan.md @@ -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` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。 diff --git a/initialize/migrate/database/02161_subscribe_application_simnet.down.sql b/initialize/migrate/database/02161_subscribe_application_simnet.down.sql new file mode 100644 index 0000000..321472a --- /dev/null +++ b/initialize/migrate/database/02161_subscribe_application_simnet.down.sql @@ -0,0 +1 @@ +DELETE FROM `subscribe_application` WHERE `id` = 1001 AND `name` = 'OmnXT SimNet'; diff --git a/initialize/migrate/database/02161_subscribe_application_simnet.up.sql b/initialize/migrate/database/02161_subscribe_application_simnet.up.sql new file mode 100644 index 0000000..d20997f --- /dev/null +++ b/initialize/migrate/database/02161_subscribe_application_simnet.up.sql @@ -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)); diff --git a/internal/handler/public/subscribe/queryUserSubscribeNodeListHandler.go b/internal/handler/public/subscribe/queryUserSubscribeNodeListHandler.go index 16c67fa..2fdc1ae 100644 --- a/internal/handler/public/subscribe/queryUserSubscribeNodeListHandler.go +++ b/internal/handler/public/subscribe/queryUserSubscribeNodeListHandler.go @@ -11,7 +11,7 @@ import ( func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) 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() result.HttpResult(c, resp, err) } diff --git a/internal/logic/admin/server/createServerLogic.go b/internal/logic/admin/server/createServerLogic.go index 021a325..494d55d 100644 --- a/internal/logic/admin/server/createServerLogic.go +++ b/internal/logic/admin/server/createServerLogic.go @@ -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) } diff --git a/internal/logic/admin/server/filterServerListLogic.go b/internal/logic/admin/server/filterServerListLogic.go index fb47ffa..f0a3fc8 100644 --- a/internal/logic/admin/server/filterServerListLogic.go +++ b/internal/logic/admin/server/filterServerListLogic.go @@ -54,6 +54,10 @@ func (l *FilterServerListLogic) FilterServerList(req *types.FilterServerListRequ l.Errorf("[FilterServerList] UnmarshalProtocols Error: %s", err.Error()) continue } + // Normalize simnet defaults on the response (safe: dst is a fresh slice). + for i := range dst { + dst[i].NormalizeSimnet() + } tool.DeepCopy(&protocols, dst) server.Protocols = protocols diff --git a/internal/logic/admin/server/getServerProtocolsLogic.go b/internal/logic/admin/server/getServerProtocolsLogic.go index 66519d9..3265553 100644 --- a/internal/logic/admin/server/getServerProtocolsLogic.go +++ b/internal/logic/admin/server/getServerProtocolsLogic.go @@ -41,6 +41,12 @@ func (l *GetServerProtocolsLogic) GetServerProtocols(req *types.GetServerProtoco l.Errorf("[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) return &types.GetServerProtocolsResponse{ diff --git a/internal/logic/admin/server/updateServerLogic.go b/internal/logic/admin/server/updateServerLogic.go index f53a19d..de3a385 100644 --- a/internal/logic/admin/server/updateServerLogic.go +++ b/internal/logic/admin/server/updateServerLogic.go @@ -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) } err = data.MarshalProtocols(protocols) diff --git a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go index 87773fe..0659ff8 100644 --- a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go +++ b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go @@ -13,6 +13,7 @@ import ( "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "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/xerr" "github.com/pkg/errors" @@ -20,16 +21,18 @@ import ( type QueryUserSubscribeNodeListLogic struct { logger.Logger - ctx context.Context - svcCtx *svc.ServiceContext + ctx context.Context + svcCtx *svc.ServiceContext + userAgent string } // 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{ - Logger: logger.WithContext(ctx), - ctx: ctx, - svcCtx: svcCtx, + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + userAgent: userAgent, } } @@ -163,6 +166,12 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u if server == nil { 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{ Id: n.Id, Name: n.Name, diff --git a/internal/logic/server/constant.go b/internal/logic/server/constant.go index 40a9113..a62ef53 100644 --- a/internal/logic/server/constant.go +++ b/internal/logic/server/constant.go @@ -12,6 +12,7 @@ const ( // Deprecated: Hysteria2 is deprecated, use Hysteria instead // TODO: remove in future versions Hysteria2 = "hysteria2" + Simnet = "simnet" ) type SecurityConfig struct { diff --git a/internal/logic/server/getServerConfigLogic.go b/internal/logic/server/getServerConfigLogic.go index 2b7ea3b..d48c64b 100644 --- a/internal/logic/server/getServerConfigLogic.go +++ b/internal/logic/server/getServerConfigLogic.go @@ -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{} s, _ := json.Marshal(result) diff --git a/internal/logic/server/getServerUserListLogic_test.go b/internal/logic/server/getServerUserListLogic_test.go index 47a1d79..978f13e 100644 --- a/internal/logic/server/getServerUserListLogic_test.go +++ b/internal/logic/server/getServerUserListLogic_test.go @@ -38,6 +38,7 @@ func TestNormalizeServerUserListProtocol(t *testing.T) { {"tuic unchanged", "tuic", "tuic"}, {"shadowsocks unchanged", "shadowsocks", "shadowsocks"}, {"anytls unchanged", "anytls", "anytls"}, + {"simnet unchanged", "simnet", "simnet"}, {"empty unchanged", "", ""}, } for _, c := range cases { diff --git a/internal/logic/subscribe/simnet_filter.go b/internal/logic/subscribe/simnet_filter.go new file mode 100644 index 0000000..e4573b1 --- /dev/null +++ b/internal/logic/subscribe/simnet_filter.go @@ -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 +} diff --git a/internal/logic/subscribe/subscribeLogic.go b/internal/logic/subscribe/subscribeLogic.go index 3907b71..57d2322 100644 --- a/internal/logic/subscribe/subscribeLogic.go +++ b/internal/logic/subscribe/subscribeLogic.go @@ -101,6 +101,11 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc if err != nil { 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( targetApp.SubscribeTemplate, adapter.WithServers(servers), @@ -109,6 +114,7 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc adapter.WithOutputFormat(targetApp.OutputFormat), adapter.WithUserInfo(adapter.User{ Password: userSubscribe.UUID, + SubscribeID: userSubscribe.Id, ExpiredAt: userSubscribe.ExpireTime, Download: userSubscribe.Download, Upload: userSubscribe.Upload, diff --git a/internal/model/node/model.go b/internal/model/node/model.go index 0f5366c..f2f21c4 100644 --- a/internal/model/node/model.go +++ b/internal/model/node/model.go @@ -38,6 +38,7 @@ var AllProtocols = []string{ "tuic", "hysteria", "hysteria2", + "simnet", } // ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。 diff --git a/internal/model/node/model_test.go b/internal/model/node/model_test.go index b8a98ee..bb30cae 100644 --- a/internal/model/node/model_test.go +++ b/internal/model/node/model_test.go @@ -63,6 +63,7 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) { "tuic", "hysteria", "hysteria2", + "simnet", } set := make(map[string]struct{}, len(AllProtocols)) for _, p := range AllProtocols { diff --git a/internal/model/node/server.go b/internal/model/node/server.go index 8431a57..051bb6b 100644 --- a/internal/model/node/server.go +++ b/internal/model/node/server.go @@ -2,6 +2,8 @@ package node import ( "encoding/json" + "fmt" + "strings" "time" "github.com/perfect-panel/server/pkg/logger" @@ -71,17 +73,20 @@ func (m *Server) BeforeUpdate(tx *gorm.DB) error { 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 { var validate = make(map[string]bool) for _, protocol := range list { - if protocol.Type == "" { + if strings.TrimSpace(protocol.Type) == "" { return errors.New("protocol type is required") } - if _, exists := validate[protocol.Type]; exists { - return errors.New("duplicate protocol type: " + protocol.Type) + key := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(protocol.Type)), protocol.Port) + 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) if err != nil { @@ -153,6 +158,192 @@ type Protocol struct { CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self` CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate 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 diff --git a/internal/types/types.go b/internal/types/types.go index b9a18a0..90eeef5 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2302,53 +2302,98 @@ type PromoUsage struct { } type Protocol struct { - Type string `json:"type"` - Port uint16 `json:"port"` - Enable bool `json:"enable"` - Security string `json:"security,omitempty"` - SNI string `json:"sni,omitempty"` - AllowInsecure bool `json:"allow_insecure,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - RealityServerAddr string `json:"reality_server_addr,omitempty"` - RealityServerPort int `json:"reality_server_port,omitempty"` - RealityPrivateKey string `json:"reality_private_key,omitempty"` - RealityPublicKey string `json:"reality_public_key,omitempty"` - RealityShortId string `json:"reality_short_id,omitempty"` - Transport string `json:"transport,omitempty"` - Host string `json:"host,omitempty"` - Path string `json:"path,omitempty"` - ServiceName string `json:"service_name,omitempty"` - Cipher string `json:"cipher,omitempty"` - ServerKey string `json:"server_key,omitempty"` - Flow string `json:"flow,omitempty"` - HopPorts string `json:"hop_ports,omitempty"` - HopInterval int `json:"hop_interval,omitempty"` - ObfsPassword string `json:"obfs_password,omitempty"` - DisableSNI bool `json:"disable_sni,omitempty"` - ReduceRtt bool `json:"reduce_rtt,omitempty"` - UDPRelayMode string `json:"udp_relay_mode,omitempty"` - CongestionController string `json:"congestion_controller,omitempty"` - Multiplex string `json:"multiplex,omitempty"` // mux, eg: off/low/medium/high - PaddingScheme string `json:"padding_scheme,omitempty"` // padding scheme - UpMbps int `json:"up_mbps,omitempty"` // upload speed limit - DownMbps int `json:"down_mbps,omitempty"` // download speed limit - Obfs string `json:"obfs,omitempty"` // obfs, 'none', 'http', 'tls' - ObfsHost string `json:"obfs_host,omitempty"` // obfs host - ObfsPath string `json:"obfs_path,omitempty"` // obfs path - XhttpMode string `json:"xhttp_mode,omitempty"` // xhttp mode - XhttpExtra string `json:"xhttp_extra,omitempty"` // xhttp extra path - Encryption string `json:"encryption,omitempty"` // encryption,'none', 'mlkem768x25519plus' - EncryptionMode string `json:"encryption_mode,omitempty"` // encryption mode,'native', 'xorpub', 'random' - EncryptionRtt string `json:"encryption_rtt,omitempty"` // encryption rtt,'0rtt', '1rtt' - EncryptionTicket string `json:"encryption_ticket,omitempty"` // encryption ticket - EncryptionServerPadding string `json:"encryption_server_padding,omitempty"` // encryption server padding - EncryptionPrivateKey string `json:"encryption_private_key,omitempty"` // encryption private key - EncryptionClientPadding string `json:"encryption_client_padding,omitempty"` // encryption client padding - EncryptionPassword string `json:"encryption_password,omitempty"` // encryption password - Ratio float64 `json:"ratio,omitempty"` // Traffic ratio, default is 1 - CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self` - CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate - CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider + Type string `json:"type"` + Port uint16 `json:"port"` + Enable bool `json:"enable"` + Security string `json:"security,omitempty"` + SNI string `json:"sni,omitempty"` + AllowInsecure bool `json:"allow_insecure,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + RealityServerAddr string `json:"reality_server_addr,omitempty"` + RealityServerPort int `json:"reality_server_port,omitempty"` + RealityPrivateKey string `json:"reality_private_key,omitempty"` + RealityPublicKey string `json:"reality_public_key,omitempty"` + RealityShortId string `json:"reality_short_id,omitempty"` + Transport string `json:"transport,omitempty"` + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + ServiceName string `json:"service_name,omitempty"` + Cipher string `json:"cipher,omitempty"` + ServerKey string `json:"server_key,omitempty"` + Flow string `json:"flow,omitempty"` + HopPorts string `json:"hop_ports,omitempty"` + HopInterval int `json:"hop_interval,omitempty"` + ObfsPassword string `json:"obfs_password,omitempty"` + DisableSNI bool `json:"disable_sni,omitempty"` + ReduceRtt bool `json:"reduce_rtt,omitempty"` + UDPRelayMode string `json:"udp_relay_mode,omitempty"` + CongestionController string `json:"congestion_controller,omitempty"` + Multiplex string `json:"multiplex,omitempty"` // mux, eg: off/low/medium/high + PaddingScheme string `json:"padding_scheme,omitempty"` // padding scheme + UpMbps int `json:"up_mbps,omitempty"` // upload speed limit + DownMbps int `json:"down_mbps,omitempty"` // download speed limit + Obfs string `json:"obfs,omitempty"` // obfs, 'none', 'http', 'tls' + ObfsHost string `json:"obfs_host,omitempty"` // obfs host + ObfsPath string `json:"obfs_path,omitempty"` // obfs path + XhttpMode string `json:"xhttp_mode,omitempty"` // xhttp mode + XhttpExtra string `json:"xhttp_extra,omitempty"` // xhttp extra path + Encryption string `json:"encryption,omitempty"` // encryption,'none', 'mlkem768x25519plus' + EncryptionMode string `json:"encryption_mode,omitempty"` // encryption mode,'native', 'xorpub', 'random' + EncryptionRtt string `json:"encryption_rtt,omitempty"` // encryption rtt,'0rtt', '1rtt' + EncryptionTicket string `json:"encryption_ticket,omitempty"` // encryption ticket + EncryptionServerPadding string `json:"encryption_server_padding,omitempty"` // encryption server padding + EncryptionPrivateKey string `json:"encryption_private_key,omitempty"` // encryption private key + EncryptionClientPadding string `json:"encryption_client_padding,omitempty"` // encryption client padding + EncryptionPassword string `json:"encryption_password,omitempty"` // encryption password + Ratio float64 `json:"ratio,omitempty"` // Traffic ratio, default is 1 + CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self` + CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate + 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 { diff --git a/pkg/simnet/simnet.go b/pkg/simnet/simnet.go new file mode 100644 index 0000000..e851a2f --- /dev/null +++ b/pkg/simnet/simnet.go @@ -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 +}