mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 16:53:45 +00:00
fix(subscription): namespace SIP008 custom date fields
This commit is contained in:
@@ -13,12 +13,18 @@ struct SubscriptionInformation: Codable, Equatable, Sendable {
|
||||
|
||||
/// 与服务器及分组扩展的解码分别处理;无效附加资料只影响该项。
|
||||
static func parse(_ data: Data) -> Self {
|
||||
let root = rootFields(data)
|
||||
let root = objectFields(data, names: ["bytes_used", "bytes_remaining", "x_shadowsocksx_ng"])
|
||||
let extensionFields = objectFields(
|
||||
root["x_shadowsocksx_ng"] ?? Data(),
|
||||
names: ["schema_version", "expires_at", "traffic_reset_at"])
|
||||
let schemaVersion = extensionFields["schema_version"].flatMap {
|
||||
try? JSONDecoder().decode(Int.self, from: $0)
|
||||
}
|
||||
return Self(
|
||||
bytesUsed: byteCount(root["bytes_used"]),
|
||||
bytesRemaining: byteCount(root["bytes_remaining"]),
|
||||
expiresAt: date(root["expires_at"]),
|
||||
trafficResetAt: date(root["traffic_reset_at"]))
|
||||
expiresAt: schemaVersion == 1 ? date(extensionFields["expires_at"]) : nil,
|
||||
trafficResetAt: schemaVersion == 1 ? date(extensionFields["traffic_reset_at"]) : nil)
|
||||
}
|
||||
|
||||
private static func byteCount(_ value: Data?) -> UInt64? {
|
||||
@@ -77,11 +83,10 @@ struct SubscriptionInformation: Codable, Equatable, Sendable {
|
||||
let minutes = Int(offset.suffix(2))!
|
||||
return (hours * 60 + minutes) * 60 * (offset.first == "-" ? -1 : 1)
|
||||
}
|
||||
/// JSON 结构已由订阅文档解析器校验。这里只切出根级白名单值的原始片段,
|
||||
/// JSON 结构已由订阅文档解析器校验。这里只切出对象直接成员中白名单值的原始片段,
|
||||
/// 不把整个正文转换成 Any;某项超大数字因此不会损坏其他项的解码。
|
||||
private static func rootFields(_ data: Data) -> [String: Data] {
|
||||
private static func objectFields(_ data: Data, names: Set<String>) -> [String: Data] {
|
||||
let bytes = Array(data)
|
||||
let names: Set<String> = ["bytes_used", "bytes_remaining", "expires_at", "traffic_reset_at"]
|
||||
var result: [String: Data] = [:]
|
||||
// 与现有 JSONDecoder 一致,接受可选的 UTF-8 BOM。
|
||||
var cursor = bytes.starts(with: [0xEF, 0xBB, 0xBF]) ? 3 : 0
|
||||
|
||||
@@ -4,7 +4,7 @@ import Testing
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
struct SubscriptionInformationParserTests {
|
||||
@Test func subscriptionInformationIsParsedFromRootFields() throws {
|
||||
@Test func subscriptionInformationUsesStandardRootAndNamespacedDateFields() throws {
|
||||
let snapshot = try SubscriptionDocumentParser.parse(
|
||||
Data(
|
||||
#"""
|
||||
@@ -12,8 +12,9 @@ struct SubscriptionInformationParserTests {
|
||||
"servers":[],
|
||||
"bytes_used":25,
|
||||
"bytes_remaining":75,
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"expires_at":"2026-10-04T08:30:00+08:00",
|
||||
"traffic_reset_at":"2026-10-05T00:00:00.125Z"}
|
||||
"traffic_reset_at":"2026-10-05T00:00:00.125Z"}}
|
||||
"""#
|
||||
.utf8),
|
||||
subscriptionID: .fresh())
|
||||
@@ -25,7 +26,9 @@ struct SubscriptionInformationParserTests {
|
||||
}
|
||||
@Test func rfc3339OffsetOutsideTimeZoneFactoryRangeIsAccepted() throws {
|
||||
let snapshot = try SubscriptionDocumentParser.parse(
|
||||
Data(#"{"version":1,"servers":[],"expires_at":"2026-10-04T23:59:00+23:59"}"#.utf8),
|
||||
Data(
|
||||
#"{"version":1,"servers":[],"x_shadowsocksx_ng":{"schema_version":1,"expires_at":"2026-10-04T23:59:00+23:59"}}"#
|
||||
.utf8),
|
||||
subscriptionID: .fresh())
|
||||
#expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_072_000))
|
||||
}
|
||||
@@ -36,7 +39,13 @@ struct SubscriptionInformationParserTests {
|
||||
])
|
||||
func invalidByteCountsDoNotDiscardValidInformation(_ literal: String) throws {
|
||||
let snapshot = try parse(
|
||||
#""bytes_used":\#(literal),"bytes_remaining":75,"expires_at":"1970-01-01T00:00:00Z""#)
|
||||
#"""
|
||||
"bytes_used":\#(literal),
|
||||
"bytes_remaining":75,
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"expires_at":"1970-01-01T00:00:00Z"}
|
||||
"""#
|
||||
)
|
||||
#expect(snapshot.information.bytesUsed == nil)
|
||||
#expect(snapshot.information.bytesRemaining == 75)
|
||||
#expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 0))
|
||||
@@ -56,7 +65,13 @@ struct SubscriptionInformationParserTests {
|
||||
])
|
||||
func invalidDateDoesNotAffectOtherFields(_ literal: String) throws {
|
||||
let snapshot = try parse(
|
||||
#""expires_at":\#(literal),"traffic_reset_at":"2026-10-04T00:00:00Z","bytes_used":0"#)
|
||||
#"""
|
||||
"bytes_used":0,
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"expires_at":\#(literal),
|
||||
"traffic_reset_at":"2026-10-04T00:00:00Z"}
|
||||
"""#
|
||||
)
|
||||
#expect(snapshot.information.expiresAt == nil)
|
||||
#expect(snapshot.information.trafficResetAt == Date(timeIntervalSince1970: 1_791_072_000))
|
||||
#expect(snapshot.information.bytesUsed == 0)
|
||||
@@ -64,7 +79,12 @@ struct SubscriptionInformationParserTests {
|
||||
|
||||
@Test func rfc3339CaseOffsetsAndLeapSecondPreserveInstants() throws {
|
||||
let snapshot = try parse(
|
||||
#""expires_at":"2026-10-03t19:00:00-05:00","traffic_reset_at":"2016-12-31T23:59:60Z""#)
|
||||
#"""
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"expires_at":"2026-10-03t19:00:00-05:00",
|
||||
"traffic_reset_at":"2016-12-31T23:59:60Z"}
|
||||
"""#
|
||||
)
|
||||
#expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_072_000))
|
||||
#expect(snapshot.information.trafficResetAt == Date(timeIntervalSince1970: 1_483_228_800))
|
||||
}
|
||||
@@ -73,7 +93,10 @@ struct SubscriptionInformationParserTests {
|
||||
let treeText = try #require(String(bytes: SubscriptionDocs.tree(), encoding: .utf8))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let validTree = try SubscriptionDocumentParser.parse(
|
||||
Data((treeText.dropLast() + #","bytes_used":25,"expires_at":123}"#).utf8),
|
||||
Data(
|
||||
(treeText.replacingOccurrences(
|
||||
of: "\"schema_version\": 1", with: "\"schema_version\": 1,\"expires_at\":123"
|
||||
).dropLast() + #","bytes_used":25}"#).utf8),
|
||||
subscriptionID: .fresh())
|
||||
#expect(validTree.root.name == "Example subscription")
|
||||
#expect(validTree.information.bytesUsed == 25)
|
||||
@@ -84,18 +107,21 @@ struct SubscriptionInformationParserTests {
|
||||
#expect(invalidTree.information.bytesRemaining == 75)
|
||||
}
|
||||
|
||||
@Test func onlyRootFieldsAreReadAcrossNestedEscapedAndUnrepresentableValues() throws {
|
||||
@Test func onlyExpectedPathsAreReadAcrossNestedEscapedAndUnrepresentableValues() throws {
|
||||
let snapshot = try SubscriptionDocumentParser.parse(
|
||||
Data(
|
||||
#"""
|
||||
{"version":1,"servers":[{"server":"203.0.113.7","server_port":8388,
|
||||
"password":"{\"bytes_used\":999},\\tail","method":"aes-256-gcm","bytes_remaining":999}],
|
||||
"ignored":1e999,"other":{"expires_at":"1970-01-01T00:00:00Z"},
|
||||
"bytes_\u0075sed":25,"bytes_remaining":75,"expires_at":"2026-10-04T00:00:00Z"}
|
||||
"bytes_\u0075sed":25,"bytes_remaining":75,"expires_at":"1970-01-01T00:00:00Z",
|
||||
"x_shadowsocksx_ng":{"schema_version":1,"ignored":1e999,"bytes_used":999,
|
||||
"expires_\u0061t":"2026-10-04T00:00:00Z","other":{"traffic_reset_at":"1970-01-01T00:00:00Z"}}}
|
||||
"""#.utf8), subscriptionID: .fresh())
|
||||
#expect(snapshot.information.bytesUsed == 25)
|
||||
#expect(snapshot.information.bytesRemaining == 75)
|
||||
#expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 1_791_072_000))
|
||||
#expect(snapshot.information.trafficResetAt == nil)
|
||||
#expect(snapshot.root.children.count == 1)
|
||||
}
|
||||
|
||||
@@ -108,6 +134,56 @@ struct SubscriptionInformationParserTests {
|
||||
#expect(snapshot.information.bytesRemaining == 75)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"null", "[]", "true", "1", #""text""#, "{}",
|
||||
#"{"schema_version":99}"#, #"{"schema_version":null}"#,
|
||||
#"{"schema_version":true}"#, #"{"schema_version":"1"}"#,
|
||||
#"{"schema_version":1e999}"#,
|
||||
#"{"expires_at":"1970-01-01T00:00:00Z"}"#,
|
||||
#"{"schema_version":99,"expires_at":"1970-01-01T00:00:00Z"}"#,
|
||||
])
|
||||
func invalidNamespaceOrVersionDoesNotReadDatesOrDiscardTraffic(_ literal: String) throws {
|
||||
let snapshot = try parse(
|
||||
#"""
|
||||
"bytes_used":25,
|
||||
"bytes_remaining":75,
|
||||
"expires_at":"1970-01-01T00:00:00Z",
|
||||
"traffic_reset_at":"1970-01-01T00:00:00Z",
|
||||
"x_shadowsocksx_ng":\#(literal)
|
||||
"""#
|
||||
)
|
||||
#expect(snapshot.information.bytesUsed == 25)
|
||||
#expect(snapshot.information.bytesRemaining == 75)
|
||||
#expect(snapshot.information.expiresAt == nil)
|
||||
#expect(snapshot.information.trafficResetAt == nil)
|
||||
}
|
||||
|
||||
@Test func rootDatesAreIgnoredWithoutNamespaceFallback() throws {
|
||||
let snapshot = try parse(
|
||||
#""expires_at":"1970-01-01T00:00:00Z","traffic_reset_at":"1970-01-01T00:00:00Z""#)
|
||||
#expect(snapshot.information.isEmpty)
|
||||
let invalidDate = try parse(
|
||||
#""expires_at":"1970-01-01T00:00:00Z","x_shadowsocksx_ng":{"schema_version":1,"expires_at":null}"#
|
||||
)
|
||||
#expect(invalidDate.information.isEmpty)
|
||||
}
|
||||
|
||||
@Test func invalidTreeAndPolicyDoNotDiscardValidDates() throws {
|
||||
let snapshot = try parse(
|
||||
#"""
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"groups":false,
|
||||
"auto_update":false,
|
||||
"expires_at":"1970-01-01T00:00:00Z",
|
||||
"traffic_reset_at":"1970-01-02T00:00:00Z"}
|
||||
"""#
|
||||
)
|
||||
#expect(snapshot.information.expiresAt == Date(timeIntervalSince1970: 0))
|
||||
#expect(snapshot.information.trafficResetAt == Date(timeIntervalSince1970: 86_400))
|
||||
#expect(snapshot.root.children.isEmpty)
|
||||
#expect(snapshot.autoUpdatePolicy == .invalid)
|
||||
}
|
||||
|
||||
private func parse(_ fields: String) throws -> SubscriptionSnapshot {
|
||||
try SubscriptionDocumentParser.parse(
|
||||
Data("{\"version\":1,\"servers\":[],\(fields)}".utf8), subscriptionID: .fresh())
|
||||
|
||||
@@ -11,6 +11,8 @@ struct SubscriptionInformationWorkflowTests {
|
||||
let summary = try await workflow.createSubscription(urlString: "https://p.example.com/sub")
|
||||
#expect(summary.information?.bytesUsed == 25)
|
||||
#expect(summary.information?.bytesRemaining == 75)
|
||||
#expect(summary.information?.expiresAt == Date(timeIntervalSince1970: 0))
|
||||
#expect(summary.information?.trafficResetAt == Date(timeIntervalSince1970: 86_400))
|
||||
let succeededAt = try #require(summary.lastSucceededAt)
|
||||
if case .succeeded(let attemptAt) = summary.status {
|
||||
#expect(succeededAt == attemptAt)
|
||||
@@ -35,13 +37,24 @@ struct SubscriptionInformationWorkflowTests {
|
||||
fetcher.setBehavior(
|
||||
.success(
|
||||
Data(
|
||||
#"{"version":1,"servers":[],"bytes_used":50,"bytes_remaining":-1,"expires_at":123}"#
|
||||
.utf8)))
|
||||
#"""
|
||||
{"version":1,
|
||||
"servers":[],
|
||||
"bytes_used":50,
|
||||
"bytes_remaining":-1,
|
||||
"expires_at":"1970-01-01T00:00:00Z",
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"expires_at":123}}
|
||||
"""#
|
||||
.utf8)))
|
||||
await workflow.refreshSubscription(first.id)
|
||||
let replaced = try #require(workflow.subscriptions.first)
|
||||
#expect(replaced.information?.bytesUsed == 50)
|
||||
#expect(replaced.information?.bytesRemaining == nil)
|
||||
#expect(first.information?.expiresAt != nil)
|
||||
#expect(first.information?.trafficResetAt != nil)
|
||||
#expect(replaced.information?.expiresAt == nil)
|
||||
#expect(replaced.information?.trafficResetAt == nil)
|
||||
|
||||
fetcher.setBehavior(.success(Data(#"{"version":1,"servers":[]}"#.utf8)))
|
||||
await workflow.refreshSubscription(first.id)
|
||||
@@ -149,7 +162,17 @@ struct SubscriptionInformationWorkflowTests {
|
||||
let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json"))
|
||||
let fetcher = FakeSubscriptionFetcher(
|
||||
behavior: .success(
|
||||
Data(#"{"version":1,"servers":[],"bytes_used":25,"bytes_remaining":75}"#.utf8)))
|
||||
Data(
|
||||
#"""
|
||||
{"version":1,
|
||||
"servers":[],
|
||||
"bytes_used":25,
|
||||
"bytes_remaining":75,
|
||||
"x_shadowsocksx_ng":{"schema_version":1,
|
||||
"expires_at":"1970-01-01T00:00:00Z",
|
||||
"traffic_reset_at":"1970-01-02T00:00:00Z"}}
|
||||
"""#
|
||||
.utf8)))
|
||||
let workflow = makeCatalogWorkflow(
|
||||
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()),
|
||||
subscriptionFetcher: fetcher)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# 订阅自定义字段统一使用项目扩展命名空间
|
||||
|
||||
订阅协议的自定义字段集中于根对象的 `x_shadowsocksx_ng`,以明确项目扩展与 SIP008 标准字段的边界。已确认将 `expires_at` 与 `traffic_reset_at` 从根对象移入该对象,今后本项目新增的扩展字段也归入该命名空间;标准字段 `version`、`servers`、`bytes_used`、`bytes_remaining` 保持原位。
|
||||
|
||||
NG2 尚未正式发布,选择直接统一输入格式,不保留根层时间字段的兼容读取。旧位置按未知字段忽略,即使新位置缺失或无效也不回读旧位置;仅提供旧位置的订阅仍可更新服务器,但不再提供这两个时间信息。
|
||||
|
||||
沿用 `schema_version: 1`,两个时间字段直接置于 `x_shadowsocksx_ng` 下,不再增加资料子对象。该对象可以只包含版本与时间字段,无需提供分组树。今后本项目新增的自定义字段均放在根对象的这个命名空间内,不在根层或 `servers[]` 中另设项目扩展字段;未来字段的具体结构在引入对应功能时决定。
|
||||
|
||||
时间字段仍使用现有 RFC 3339 格式并逐项解析。扩展对象缺失、不是对象、版本缺失或不支持时,不读取其中的时间字段。受支持版本内,分组树、自动更新策略与时间字段独立解析,一项功能内容错误不影响其他有效功能;标准流量字段也不受扩展错误影响。
|
||||
|
||||
每次成功刷新以本次解析结果替换订阅资料,缺失或无效的时间字段清除对应旧值;网络、文档解析或保存失败时保留原资料。不采用自动更新策略的「无效时保留上次有效值」规则,避免时间资料继续呈现供应方本次未有效提供的旧事实。
|
||||
|
||||
本地已保存的时间资料保留到下次成功刷新,不迁移持久化格式。配置组导出继续只包含标准服务器列表与分组扩展,不携带订阅时间、流量或供应商自动更新策略;文件导入仍只创建独立手动子树。
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
See the [SIP-008 subscription extension guide](subscription-provider-extension.md) for field formats, the nested-group extension, and response requirements for subscription providers.
|
||||
|
||||
A SIP-008 JSON root may contain `bytes_used` and `bytes_remaining` (non-negative integers up to UInt64), plus the custom fields `expires_at` (service expiration time) and `traffic_reset_at` (traffic reset time). Date values use RFC 3339 strings; an omitted field or `null` means no value was provided. An invalid item is ignored without affecting valid server or group information.
|
||||
A SIP-008 JSON root may contain `bytes_used` and `bytes_remaining` (non-negative integers up to UInt64), with the custom fields `expires_at` (service expiration time) and `traffic_reset_at` (traffic reset time) inside the root-level `x_shadowsocksx_ng` object with `schema_version: 1`. Root-level date fields are ignored. A missing or invalid namespace, or a missing or unsupported version, provides no date information; standard traffic fields remain independently available. Date values use RFC 3339 strings; an omitted field or `null` means no value was provided. An invalid item is ignored without affecting valid server or group information.
|
||||
|
||||
A successful refresh replaces all allowlisted subscription information. Omitted or invalid fields clear their previous values; a failure or cancellation preserves the last successful information and its separate success timestamp. Information and the catalog record are saved atomically in the same document. The app does not separately save the raw response or a plaintext snapshot of server data containing passwords. Passwords and plugin parameters continue to use Keychain storage. If an older catalog file has no success timestamp, the app does not infer one.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
面向订阅服务提供方的字段格式、嵌套分组扩展和响应要求见 [SIP-008 订阅扩展说明](subscription-provider-extension.zh.md)。
|
||||
|
||||
订阅可在 SIP-008 JSON 根级提供 `bytes_used`、`bytes_remaining`(非负整数,最大为 UInt64),以及自定义的 `expires_at`(服务到期时间)、`traffic_reset_at`(流量重置时间)。日期值使用 RFC 3339 字符串,省略或 `null` 表示未提供;无效资料仅忽略该项,不影响有效服务器或分组资料。
|
||||
订阅可在 SIP-008 JSON 根级提供 `bytes_used`、`bytes_remaining`(非负整数,最大为 UInt64),并在根层 `x_shadowsocksx_ng` 对象中以 `schema_version: 1` 提供自定义的 `expires_at`(服务到期时间)、`traffic_reset_at`(流量重置时间)。根层时间字段不再读取。命名空间缺失或无效、版本缺失或不支持时,视为未提供时间资料;标准流量字段仍独立可用。日期值使用 RFC 3339 字符串,省略或 `null` 表示未提供;无效资料仅忽略该项,不影响有效服务器或分组资料。
|
||||
|
||||
成功刷新完整替换白名单订阅资料,省略或无效的字段清除旧值;失败或取消保留最后成功资料与独立成功时间。资料和目录记录同文档原子保存,不另存原始响应或含密码的服务器明文快照;密码和插件参数沿用 Keychain 存储。旧目录文件缺少成功时间时不猜测。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This guide describes the SIP-008 fields and private extensions currently support
|
||||
|
||||
[简体中文](subscription-provider-extension.zh.md)
|
||||
|
||||
`x_shadowsocksx_ng`, `expires_at`, and `traffic_reset_at` are private extensions recognized by ShadowsocksX-NG; they are not SIP-008 standard fields. Ordinary SIP-008 clients may not recognize them, and clients without extension support should still read the servers from the standard `servers` array. SIP-008 allows custom fields but does not guarantee that all clients support them. See the [SIP-008 Online Configuration Delivery specification](https://shadowsocks.org/doc/sip008.html).
|
||||
`x_shadowsocksx_ng` is the namespace for all ShadowsocksX-NG private extensions, including `expires_at` and `traffic_reset_at`. These are not SIP-008 standard fields. Future project-specific fields must also live inside this root-level namespace, rather than directly in the root or server objects. Ordinary SIP-008 clients may not recognize them, and clients without extension support should still read the servers from the standard `servers` array. SIP-008 allows custom fields but does not guarantee that all clients support them. See the [SIP-008 Online Configuration Delivery specification](https://shadowsocks.org/doc/sip008.html).
|
||||
|
||||
## Response delivery
|
||||
|
||||
@@ -35,7 +35,7 @@ The root object must contain `version: 1` and a `servers` array. An empty array
|
||||
|
||||
A server ID represents logical identity, not its array position. Preserve the ID when renaming a server, changing its address or port, or updating its credentials if it remains the same logical server. Assign a new ID to a new logical server. Use the canonical hyphenated UUID form, and keep the exact same string in the server entry and extension references.
|
||||
|
||||
## Root-level subscription information
|
||||
## Subscription information
|
||||
|
||||
The following traffic fields are defined by SIP-008. Providers should follow SIP-008's rules for these fields. The client displays only values that match the formats below.
|
||||
|
||||
@@ -43,14 +43,16 @@ The following traffic fields are defined by SIP-008. Providers should follow SIP
|
||||
| --- | --- | --- |
|
||||
| `bytes_used` | A non-negative JSON integer from `0` through `18446744073709551615`. | Number of bytes used by the user. Do not send a string, negative number, decimal, or exponent form. |
|
||||
| `bytes_remaining` | A non-negative JSON integer in the same range. Omit it when there is no data limit; include `bytes_used` as well when a limit is enforced. | Number of bytes remaining. Do not use a private field to repeat the same traffic meaning. |
|
||||
| `expires_at` | An RFC 3339 date-time string, such as `2027-01-01T00:00:00Z` or `2027-01-01T08:00:00+08:00`. | A ShadowsocksX-NG private field for the service expiration time. |
|
||||
| `traffic_reset_at` | An RFC 3339 date-time string. | A ShadowsocksX-NG private field for the traffic reset time. |
|
||||
| `x_shadowsocksx_ng.expires_at` | An RFC 3339 date-time string, such as `2027-01-01T00:00:00Z` or `2027-01-01T08:00:00+08:00`. | A ShadowsocksX-NG private field for the service expiration time. |
|
||||
| `x_shadowsocksx_ng.traffic_reset_at` | An RFC 3339 date-time string. | A ShadowsocksX-NG private field for the traffic reset time. |
|
||||
|
||||
Date fields are direct members of `x_shadowsocksx_ng` and require `schema_version: 1`; the namespace may contain only the version and dates, without a group tree. Root-level `expires_at` and `traffic_reset_at` are ignored, with no fallback. A missing or non-object namespace, or a missing or unsupported schema version, provides no date information. The standard traffic fields remain at the root.
|
||||
|
||||
An omitted date field or `null` means no value is provided. The client parses these fields independently. An invalid value is ignored without discarding valid servers, groups, or other valid information. The client reads this information from the JSON body; it does not parse response headers such as `Subscription-Userinfo`.
|
||||
|
||||
## Nested-group extension: `x_shadowsocksx_ng`
|
||||
## Private extension namespace: `x_shadowsocksx_ng`
|
||||
|
||||
To provide nested groups to ShadowsocksX-NG, add the following private extension to the root object. Keep every server in `servers`; the extension carries only group names, hierarchy, order, and server references.
|
||||
Add the private extension namespace to the root object. The example below includes dates and a nested group tree. Keep every server in `servers`; the group tree carries only group names, hierarchy, order, and server references. Dates, the group tree, and `auto_update` are parsed independently: invalid content in one feature does not discard valid content in another under a supported schema version.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -75,10 +77,10 @@ To provide nested groups to ShadowsocksX-NG, add the following private extension
|
||||
],
|
||||
"bytes_used": 1073741824,
|
||||
"bytes_remaining": 9663676416,
|
||||
"expires_at": "2027-01-01T00:00:00Z",
|
||||
"traffic_reset_at": "2026-11-01T00:00:00Z",
|
||||
"x_shadowsocksx_ng": {
|
||||
"schema_version": 1,
|
||||
"expires_at": "2027-01-01T00:00:00Z",
|
||||
"traffic_reset_at": "2026-11-01T00:00:00Z",
|
||||
"root_group_id": "f6ad0a41-2a1d-4db8-9da0-b6f4f3dc889d",
|
||||
"groups": [
|
||||
{
|
||||
@@ -114,7 +116,7 @@ The provider should make the extension a single-parent, acyclic tree rooted at `
|
||||
|
||||
The client handles the extension as follows:
|
||||
|
||||
- If the extension version is missing or unsupported, the root group is missing, IDs conflict, a reference is invalid, a child type is unknown, a cycle exists, or a group has multiple parents, the client ignores the entire extension and uses the flat standard `servers` list. Valid servers are not lost because of an invalid extension.
|
||||
- If the extension version is missing or unsupported, the root group is missing, IDs conflict, a reference is invalid, a child type is unknown, a cycle exists, or a group has multiple parents, the client ignores the group tree and uses the flat standard `servers` list. Valid servers are not lost because of an invalid group tree.
|
||||
- A server in `servers` that is not referenced by a valid tree is still shown at the end of the root group, in the order it appears in the `servers` array. If the extension is invalid or incomplete, servers may still appear, but their grouping is not preserved.
|
||||
- Groups unreachable from the root are not shown. Do not rely on client behavior for unreachable groups or duplicate server references; providers should produce a valid tree without orphan groups or repeated server references.
|
||||
- A record without a server `id` cannot be referenced by the tree. Provide a stable ID for every server to preserve grouping.
|
||||
@@ -124,8 +126,10 @@ The client handles the extension as follows:
|
||||
- Use HTTPS and return HTTP `200` with `Content-Type: application/json; charset=utf-8`.
|
||||
- Return valid JSON with root-level `version: 1` and complete server records in `servers`.
|
||||
- Give every server a unique, stable UUID; use the exact same server ID in `servers` and group references.
|
||||
- If using the extension, set `schema_version` to `1`, include the root group, resolve every reference, and keep the tree ordered, single-parent, and acyclic.
|
||||
- Use the SIP-008 standard fields for traffic information; use RFC 3339 strings for expiration and reset dates.
|
||||
- If using the extension, set `schema_version` to `1`. If providing a group tree, include the root group, resolve every reference, and keep the tree ordered, single-parent, and acyclic.
|
||||
- Use the SIP-008 standard fields for traffic information; use RFC 3339 strings for expiration and reset dates inside `x_shadowsocksx_ng`.
|
||||
- Verify that a standard SIP-008 client that does not support `x_shadowsocksx_ng` still receives all servers in the flat `servers` list.
|
||||
|
||||
For details about how the application presents subscription fields and handles refreshes, see [“Subscription information” in the configuration guide](configuration.md#subscription-information).
|
||||
|
||||
A successful refresh replaces subscription information; missing or invalid dates clear previous values. Fetch, document-validation, or save failures preserve the previous information. Existing locally saved dates remain until a successful refresh; no local storage migration is needed. Group exports include servers and grouping only, omitting subscription dates, traffic, and provider update policies. File imports create independent manual subtrees without retaining subscription information.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
[English](subscription-provider-extension.md)
|
||||
|
||||
`x_shadowsocksx_ng` 和 `expires_at`、`traffic_reset_at` 是 ShadowsocksX-NG 识别的私有扩展,并非 SIP-008 标准字段。普通 SIP-008 客户端不一定识别这些字段;没有扩展支持的客户端仍应从标准 `servers` 数组读取服务器。SIP-008 允许自定义字段,但不保证所有客户端支持。详见 [SIP-008 Online Configuration Delivery](https://shadowsocks.org/doc/sip008.html)。
|
||||
`x_shadowsocksx_ng` 是 ShadowsocksX-NG 所有私有扩展的命名空间,包含 `expires_at`、`traffic_reset_at`,这些都不是 SIP-008 标准字段。今后本项目新增的扩展字段也必须归入根对象的这个命名空间,不直接放在根层或服务器对象中。普通 SIP-008 客户端不一定识别这些字段;没有扩展支持的客户端仍应从标准 `servers` 数组读取服务器。SIP-008 允许自定义字段,但不保证所有客户端支持。详见 [SIP-008 Online Configuration Delivery](https://shadowsocks.org/doc/sip008.html)。
|
||||
|
||||
## 响应传输
|
||||
|
||||
@@ -35,7 +35,7 @@ SIP-008 订阅 URL 通常包含访问令牌。请使用不可猜测的令牌,
|
||||
|
||||
服务器 ID 表示逻辑身份,不是数组位置。改名、改地址、改端口或更新凭据时,如果仍是同一逻辑服务器,应保留原 ID;新增一台逻辑服务器时分配新 ID。UUID 应采用连字符分隔的规范形式,并在正文和扩展引用中保持完全相同的字符串。
|
||||
|
||||
## 根级订阅资料
|
||||
## 订阅资料
|
||||
|
||||
以下流量字段来自 SIP-008。供应方应遵守 SIP-008 对数据量字段的约定;客户端仅把符合下表格式的值显示为订阅资料。
|
||||
|
||||
@@ -43,14 +43,16 @@ SIP-008 订阅 URL 通常包含访问令牌。请使用不可猜测的令牌,
|
||||
| --- | --- | --- |
|
||||
| `bytes_used` | JSON 非负整数,范围为 `0` 至 `18446744073709551615`。 | 用户已使用的字节数。不要发送字符串、负数、小数或指数形式。 |
|
||||
| `bytes_remaining` | JSON 非负整数,范围同上。没有流量上限时省略;有限额时同时提供 `bytes_used`。 | 用户剩余可用字节数。不要用私有字段重复表达同一流量含义。 |
|
||||
| `expires_at` | RFC 3339 日期时间字符串,例如 `2027-01-01T00:00:00Z` 或 `2027-01-01T08:00:00+08:00`。 | ShadowsocksX-NG 私有字段,表示服务到期时间。 |
|
||||
| `traffic_reset_at` | RFC 3339 日期时间字符串。 | ShadowsocksX-NG 私有字段,表示流量重置时间。 |
|
||||
| `x_shadowsocksx_ng.expires_at` | RFC 3339 日期时间字符串,例如 `2027-01-01T00:00:00Z` 或 `2027-01-01T08:00:00+08:00`。 | ShadowsocksX-NG 私有字段,表示服务到期时间。 |
|
||||
| `x_shadowsocksx_ng.traffic_reset_at` | RFC 3339 日期时间字符串。 | ShadowsocksX-NG 私有字段,表示流量重置时间。 |
|
||||
|
||||
时间字段直接放在 `x_shadowsocksx_ng` 中,要求 `schema_version: 1`;该对象可以只包含版本和时间字段,无需提供分组树。根层的 `expires_at`、`traffic_reset_at` 不再读取,也不会作为兼容来源。命名空间缺失、不是对象,或版本缺失、不支持时,视为未提供时间资料。标准流量字段仍放在根层。
|
||||
|
||||
日期字段缺失或为 `null` 表示未提供。客户端逐项解析这些资料;某一项格式错误只会忽略该项,不会丢弃有效服务器、分组或其他有效资料。客户端从 JSON 正文读取资料,不解析 HTTP `Subscription-Userinfo` 等响应头。
|
||||
|
||||
## 嵌套分组扩展 `x_shadowsocksx_ng`
|
||||
## 私有扩展命名空间 `x_shadowsocksx_ng`
|
||||
|
||||
要向 ShadowsocksX-NG 提供嵌套分组,可在根对象中增加以下私有扩展。`servers` 仍须包含全部服务器;扩展只表达分组名称、层级、顺序和服务器引用。
|
||||
在根对象中增加私有扩展命名空间。以下示例同时提供时间资料和嵌套分组树。`servers` 仍须包含全部服务器;分组树只表达分组名称、层级、顺序和服务器引用。时间、分组树与 `auto_update` 独立解析:版本受支持时,一个功能内容错误不影响其他有效功能。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -75,10 +77,10 @@ SIP-008 订阅 URL 通常包含访问令牌。请使用不可猜测的令牌,
|
||||
],
|
||||
"bytes_used": 1073741824,
|
||||
"bytes_remaining": 9663676416,
|
||||
"expires_at": "2027-01-01T00:00:00Z",
|
||||
"traffic_reset_at": "2026-11-01T00:00:00Z",
|
||||
"x_shadowsocksx_ng": {
|
||||
"schema_version": 1,
|
||||
"expires_at": "2027-01-01T00:00:00Z",
|
||||
"traffic_reset_at": "2026-11-01T00:00:00Z",
|
||||
"root_group_id": "f6ad0a41-2a1d-4db8-9da0-b6f4f3dc889d",
|
||||
"groups": [
|
||||
{
|
||||
@@ -114,7 +116,7 @@ SIP-008 订阅 URL 通常包含访问令牌。请使用不可猜测的令牌,
|
||||
|
||||
客户端的扩展处理行为如下:
|
||||
|
||||
- 扩展版本缺失或不支持、根分组不存在、ID 冲突、引用无效、子项类型未知、出现环或分组被多个父分组引用时,整个扩展会被忽略,客户端改用标准 `servers` 扁平列表;有效服务器不会因此丢失。
|
||||
- 扩展版本缺失或不支持、根分组不存在、ID 冲突、引用无效、子项类型未知、出现环或分组被多个父分组引用时,分组树会被忽略,客户端改用标准 `servers` 扁平列表;有效服务器不会因此丢失。
|
||||
- 未被有效树引用但存在于 `servers` 的服务器仍会显示在根分组末尾,顺序沿用其在 `servers` 数组中的顺序。因此扩展失效或不完整时,服务器仍可能显示,但分组关系不会保留。
|
||||
- 树中不可从根到达的分组不会显示。请勿依赖客户端对不可达分组或重复服务器引用的处理;供应方应输出没有孤立分组、没有重复服务器引用的有效树。
|
||||
- 缺少服务器 `id` 的记录无法被树引用;为保证分组准确,请给所有服务器提供稳定 ID。
|
||||
@@ -124,8 +126,10 @@ SIP-008 订阅 URL 通常包含访问令牌。请使用不可猜测的令牌,
|
||||
- 使用 HTTPS,返回 HTTP `200` 和 `Content-Type: application/json; charset=utf-8`。
|
||||
- 提供合法 JSON,根级 `version` 为 `1`,`servers` 包含完整服务器记录。
|
||||
- 为每台服务器提供唯一且稳定的 UUID;服务器 ID 在 `servers` 和分组引用中逐字一致。
|
||||
- 若使用扩展,`schema_version` 为 `1`,根分组存在,所有引用有效,分组树有序、单父、无环。
|
||||
- 流量字段使用 SIP-008 标准字段;到期与重置日期使用本文规定的 RFC 3339 字符串。
|
||||
- 若使用扩展,`schema_version` 为 `1`;若提供分组树,根分组须存在,所有引用有效,树有序、单父、无环。
|
||||
- 流量字段使用 SIP-008 标准字段;到期与重置日期放在 `x_shadowsocksx_ng` 下,使用本文规定的 RFC 3339 字符串。
|
||||
- 以不支持 `x_shadowsocksx_ng` 的普通 SIP-008 客户端验证时,扁平 `servers` 列表仍包含全部服务器。
|
||||
|
||||
订阅字段的应用内呈现与刷新行为见[配置文档中的“订阅资料”](configuration.zh.md#订阅资料)。
|
||||
|
||||
成功刷新会替换订阅资料,缺失或无效的时间字段清除对应旧值;获取、文档校验或保存失败则保留原资料。本地已保存的时间保留到下次成功刷新,无需迁移本地存储。配置组导出只携带服务器和分组,不携带订阅时间、流量或供应商更新策略;文件导入创建独立手动子树,不保留订阅资料。
|
||||
|
||||
Reference in New Issue
Block a user