feat(simnet): add SimNet protocol end-to-end support

- model: SimNet protocol fields + NormalizeSimnet; type+port protocol uniqueness
- server config: OmnXT runtime config delivery via compatible() simnet case
- credentials: derive per-user psk/key_id from subscription (pkg/simnet), no new table
- subscription: adapter buildOmnxtSimnetConfigs + base64 buildOmnxtProtocolLinks + OmnXT SimNet application (migration 02161)
- UA gating: hide experimental protocols from non first-party clients (download + JSON node-list)
- admin: normalize simnet on create/update and on GET responses
- tests: 21 simnet unit tests; full suite green
This commit is contained in:
2026-07-27 00:17:02 -07:00
parent 5ef3f2717e
commit 634b5a7bd0
23 changed files with 1728 additions and 107 deletions
+24
View File
@@ -160,6 +160,30 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider,
CertDNSEnv: protocol.CertDNSEnv,
SimnetPsk: protocol.SimnetPsk,
SimnetKeyID: protocol.SimnetKeyID,
SimnetTicketID: protocol.SimnetTicketID,
SimnetPath: protocol.SimnetPath,
SimnetCarrier: protocol.SimnetCarrier,
SimnetAfEnabled: protocol.SimnetAfEnabled,
SimnetAfPathMode: protocol.SimnetAfPathMode,
SimnetAfPathPrefix: protocol.SimnetAfPathPrefix,
SimnetAfPathSuffix: protocol.SimnetAfPathSuffix,
SimnetAfMagicMode: protocol.SimnetAfMagicMode,
SimnetAfResponseJitterMs: protocol.SimnetAfResponseJitterMs,
SimnetAfHandshakePolymorphism: protocol.SimnetAfHandshakePolymorphism,
SimnetAfSettingsJitter: protocol.SimnetAfSettingsJitter,
SimnetAfFakeHeaderInjection: protocol.SimnetAfFakeHeaderInjection,
SimnetFallbackEnabled: protocol.SimnetFallbackEnabled,
SimnetFallbackTargetScheme: protocol.SimnetFallbackTargetScheme,
SimnetFallbackTargetHost: protocol.SimnetFallbackTargetHost,
SimnetFallbackTargetPort: protocol.SimnetFallbackTargetPort,
SimnetFallbackHostHeader: protocol.SimnetFallbackHostHeader,
SimnetFallbackTLSSNI: protocol.SimnetFallbackTLSSNI,
SimnetClientMaxConcurrentStreams: protocol.SimnetClientMaxConcurrentStreams,
SimnetClientMaxStreamsPerSession: protocol.SimnetClientMaxStreamsPerSession,
SimnetClientSessionIdleTimeoutSecs: protocol.SimnetClientSessionIdleTimeoutSecs,
SimnetClientMaxUDPSessions: protocol.SimnetClientMaxUDPSessions,
},
)
}
+32 -1
View File
@@ -81,10 +81,38 @@ type Proxy struct {
CertMode string // Certificate mode, `none``http``dns``self`
CertDNSProvider string // DNS provider for certificate
CertDNSEnv string // Environment for DNS provider
// Simnet Options (server-side config; per-user psk/key_id are derived at
// render time from UserInfo, never stored on the Proxy).
SimnetPsk string // server-side PSK (key_id=0), used for AF derivation
SimnetKeyID int // server key id (0)
SimnetTicketID string
SimnetPath string
SimnetCarrier string
SimnetAfEnabled bool
SimnetAfPathMode string
SimnetAfPathPrefix string
SimnetAfPathSuffix string
SimnetAfMagicMode string
SimnetAfResponseJitterMs int
SimnetAfHandshakePolymorphism bool
SimnetAfSettingsJitter bool
SimnetAfFakeHeaderInjection bool
SimnetFallbackEnabled bool
SimnetFallbackTargetScheme string
SimnetFallbackTargetHost string
SimnetFallbackTargetPort int
SimnetFallbackHostHeader string
SimnetFallbackTLSSNI string
SimnetClientMaxConcurrentStreams int
SimnetClientMaxStreamsPerSession int
SimnetClientSessionIdleTimeoutSecs int
SimnetClientMaxUDPSessions int
}
type User struct {
Password string
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
ExpiredAt time.Time
Download int64
Upload int64
@@ -104,7 +132,10 @@ type Client struct {
func (c *Client) Build() ([]byte, error) {
var buf bytes.Buffer
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
funcMap := sprig.TxtFuncMap()
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
if err != nil {
return nil, err
}
+273
View File
@@ -0,0 +1,273 @@
package adapter
import (
"encoding/base64"
"net/url"
"strconv"
"strings"
"github.com/perfect-panel/server/pkg/simnet"
)
// buildOmnxtSimnetConfigs is a subscription template function (registered in
// Client.Build) that produces the per-user OmnXT SimNet JSON config array.
//
// It mirrors the Pro reference (NPanel-backend
// internal/biz/public/subscription/template.go buildOmnxtSimnetConfigs):
// - per-user simnet_psk / simnet_key_id are DERIVED from the user's
// subscription (uuid + user_subscribe.id), never stored.
// - the server PSK (key_id=0) is passed through as simnet_server_psk so the
// client SDK can derive AF path/magic with the same key material.
//
// Template usage: {{ buildOmnxtSimnetConfigs .Proxies .UserInfo .Params | toPrettyJson }}
func buildOmnxtSimnetConfigs(proxies []map[string]interface{}, userInfo User, params map[string]string) []map[string]interface{} {
result := make([]map[string]interface{}, 0)
proxyMode := strings.TrimSpace(params["proxy_mode"])
if proxyMode == "" {
proxyMode = "global"
}
dnsServers := []string{"1.1.1.1"}
if raw := strings.TrimSpace(params["dns_servers"]); raw != "" {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r'
})
parsed := make([]string, 0, len(parts))
for _, item := range parts {
if item = strings.TrimSpace(item); item != "" {
parsed = append(parsed, item)
}
}
if len(parsed) > 0 {
dnsServers = parsed
}
}
// Per-user credentials derived from the subscription record (see pkg/simnet).
userKeyID := simnet.DeriveKeyID(userInfo.SubscribeID)
userPSK := simnet.DeriveUserPSK(userInfo.Password)
for _, proxy := range proxies {
if smString(proxy["Type"]) != "simnet" {
continue
}
afEnabled := smBool(proxy["SimnetAfEnabled"])
item := map[string]interface{}{
"tag": smString(proxy["Name"]),
"server_addr": smString(proxy["Server"]),
"server_port": smInt(proxy["Port"]),
"protocol": "simnet",
"sni": smString(proxy["SNI"]),
"allow_insecure": smBool(proxy["AllowInsecure"]),
"simnet_psk": userPSK,
"simnet_key_id": userKeyID,
// Server PSK is required for AF path/magic/content-type derivation.
"simnet_server_psk": smStringOrNil(proxy["SimnetPsk"]),
"simnet_server_key_id": smInt(proxy["SimnetKeyID"]),
"simnet_ticket_id": smStringOrNil(proxy["SimnetTicketID"]),
"simnet_path": smDefaultString(smString(proxy["SimnetPath"]), "/simnet/session"),
"simnet_carrier": smDefaultString(smString(proxy["SimnetCarrier"]), "h2"),
"simnet_af_enabled": afEnabled,
"simnet_client_max_concurrent_streams": smDefaultInt(smInt(proxy["SimnetClientMaxConcurrentStreams"]), 32),
"simnet_client_max_streams_per_session": smDefaultInt(smInt(proxy["SimnetClientMaxStreamsPerSession"]), 512),
"simnet_client_session_idle_timeout_secs": smDefaultInt(smInt(proxy["SimnetClientSessionIdleTimeoutSecs"]), 90),
"simnet_client_max_udp_sessions": smDefaultInt(smInt(proxy["SimnetClientMaxUDPSessions"]), 64),
"proxy_mode": proxyMode,
"dns_servers": dnsServers,
}
if afEnabled {
item["simnet_af_path_mode"] = smDefaultString(smString(proxy["SimnetAfPathMode"]), "api")
item["simnet_af_path_prefix"] = smStringOrNil(proxy["SimnetAfPathPrefix"])
item["simnet_af_path_suffix"] = smStringOrNil(proxy["SimnetAfPathSuffix"])
item["simnet_af_magic_mode"] = smDefaultString(smString(proxy["SimnetAfMagicMode"]), "derived")
item["simnet_af_response_jitter_ms"] = smDefaultInt(smInt(proxy["SimnetAfResponseJitterMs"]), 50)
item["simnet_af_handshake_polymorphism"] = smBool(proxy["SimnetAfHandshakePolymorphism"])
item["simnet_af_settings_jitter"] = smBool(proxy["SimnetAfSettingsJitter"])
item["simnet_af_fake_header_injection"] = smBool(proxy["SimnetAfFakeHeaderInjection"])
}
result = append(result, item)
}
return result
}
func smString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func smStringOrNil(v interface{}) interface{} {
if s, ok := v.(string); ok && s != "" {
return s
}
return nil
}
func smBool(v interface{}) bool {
b, ok := v.(bool)
return ok && b
}
func smInt(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int8:
return int(n)
case int16:
return int(n)
case int32:
return int(n)
case int64:
return int(n)
case uint:
return int(n)
case uint8:
return int(n)
case uint16:
return int(n)
case uint32:
return int(n)
case uint64:
return int(n)
case float32:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func smDefaultString(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func smDefaultInt(i, def int) int {
if i == 0 {
return def
}
return i
}
// buildOmnxtProtocolLinks wraps each simnet config into a base64 "simnet://"
// link, matching the Pro reference's final delivery format (migration 02140,
// template.go buildOmnxtProtocolLinks). Template usage:
//
// {{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}
// {{- end }}
func buildOmnxtProtocolLinks(proxies []map[string]interface{}, userInfo User, params map[string]string) []string {
configs := buildOmnxtSimnetConfigs(proxies, userInfo, params)
result := make([]string, 0, len(configs))
for _, item := range configs {
serverAddr := smString(item["server_addr"])
serverPort := smInt(item["server_port"])
tag := smString(item["tag"])
if serverAddr == "" || serverPort == 0 {
continue
}
afEnabled := smBool(item["simnet_af_enabled"])
payload := map[string]interface{}{
"protocol": "simnet",
"server_addr": serverAddr,
"server_port": serverPort,
"sni": smString(item["sni"]),
"simnet_psk": smString(item["simnet_psk"]),
"simnet_key_id": smInt(item["simnet_key_id"]),
"simnet_server_psk": item["simnet_server_psk"],
"simnet_server_key_id": smInt(item["simnet_server_key_id"]),
"simnet_ticket_id": item["simnet_ticket_id"],
"simnet_path": item["simnet_path"],
"simnet_carrier": smString(item["simnet_carrier"]),
"simnet_af_enabled": afEnabled,
"simnet_client_max_concurrent_streams": smInt(item["simnet_client_max_concurrent_streams"]),
"simnet_client_max_streams_per_session": smInt(item["simnet_client_max_streams_per_session"]),
"simnet_client_session_idle_timeout_secs": smInt(item["simnet_client_session_idle_timeout_secs"]),
"simnet_client_max_udp_sessions": smInt(item["simnet_client_max_udp_sessions"]),
"proxy_mode": item["proxy_mode"],
"dns_servers": item["dns_servers"],
}
if afEnabled {
payload["simnet_af_path_mode"] = smString(item["simnet_af_path_mode"])
payload["simnet_af_path_prefix"] = item["simnet_af_path_prefix"]
payload["simnet_af_path_suffix"] = item["simnet_af_path_suffix"]
payload["simnet_af_magic_mode"] = smString(item["simnet_af_magic_mode"])
payload["simnet_af_response_jitter_ms"] = smInt(item["simnet_af_response_jitter_ms"])
payload["simnet_af_handshake_polymorphism"] = smBool(item["simnet_af_handshake_polymorphism"])
payload["simnet_af_settings_jitter"] = smBool(item["simnet_af_settings_jitter"])
payload["simnet_af_fake_header_injection"] = smBool(item["simnet_af_fake_header_injection"])
}
encoded := encodeProtocolPayload(payload)
if encoded == "" {
continue
}
result = append(result, "simnet://"+encoded+"#"+url.QueryEscape(tag))
}
return result
}
// encodeProtocolPayload url-encodes a payload map and base64-encodes it,
// matching the reference encodeProtocolPayload.
func encodeProtocolPayload(payload map[string]interface{}) string {
values := url.Values{}
for key, value := range payload {
switch v := value.(type) {
case nil:
continue
case string:
if strings.TrimSpace(v) != "" {
values.Set(key, v)
}
case bool:
if v {
values.Set(key, "1")
}
case int:
if v != 0 {
values.Set(key, strconv.Itoa(v))
}
case int32:
if v != 0 {
values.Set(key, strconv.FormatInt(int64(v), 10))
}
case int64:
if v != 0 {
values.Set(key, strconv.FormatInt(v, 10))
}
case []string:
if len(v) > 0 {
values.Set(key, strings.Join(v, ","))
}
case []interface{}:
items := make([]string, 0, len(v))
for _, item := range v {
if s := smString(item); s != "" {
items = append(items, s)
}
}
if len(items) > 0 {
values.Set(key, strings.Join(items, ","))
}
default:
if s := smString(v); s != "" {
values.Set(key, s)
}
}
}
if len(values) == 0 {
return ""
}
return base64.StdEncoding.EncodeToString([]byte(values.Encode()))
}
+45
View File
@@ -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 {
+813
View File
@@ -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. 阶段 3OmnXT 服务端配置下发
### 目标
让 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. 阶段 5OmnXT 用户授权同步
### 目标
让 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. 阶段 8TLS、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 契约清楚、测试环境可用的情况下:
- 阶段 00.5-1 天
- 阶段 1-21.5-2 天
- 阶段 31-1.5 天
- 阶段 41.5-2 天
- 阶段 51-1.5 天
- 阶段 61-1.5 天
- 阶段 71-2 天
- 阶段 81 天
- 阶段 92-3 天
- 阶段 101 天
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
## 21. 推荐执行顺序
第一周先完成最小闭环:
1. 阶段 0:确认契约。
2. 阶段 1:协议模型与校验。
3. 阶段 2:管理端保存和查询。
4. 阶段 3OmnXT 配置下发。
第二周完成用户链路:
1. 阶段 4:用户级凭据。
2. 阶段 5OmnXT 用户授权。
3. 阶段 6SlagClient 订阅。
4. 阶段 7:流量和在线用户映射。
最后做生产化:
1. 阶段 8TLS、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) {
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)
}
@@ -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)
}
@@ -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
@@ -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{
@@ -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)
@@ -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"
@@ -22,14 +23,16 @@ type QueryUserSubscribeNodeListLogic struct {
logger.Logger
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,
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,
+1
View File
@@ -12,6 +12,7 @@ const (
// Deprecated: Hysteria2 is deprecated, use Hysteria instead
// TODO: remove in future versions
Hysteria2 = "hysteria2"
Simnet = "simnet"
)
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{}
s, _ := json.Marshal(result)
@@ -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 {
+27
View File
@@ -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 {
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,
+1
View File
@@ -38,6 +38,7 @@ var AllProtocols = []string{
"tuic",
"hysteria",
"hysteria2",
"simnet",
}
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
+1
View File
@@ -63,6 +63,7 @@ func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
"tuic",
"hysteria",
"hysteria2",
"simnet",
}
set := make(map[string]struct{}, len(AllProtocols))
for _, p := range AllProtocols {
+196 -5
View File
@@ -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
+45
View File
@@ -2349,6 +2349,51 @@ 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,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 {
+112
View File
@@ -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
}