Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let package = Package(
],
products: [
.library(name: "MCSMAPI", targets: ["MCSMAPI"]),
.library(name: "MCSMStream", targets: ["MCSMStream"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.11.0"),
Expand All @@ -34,6 +35,18 @@ let package = Package(
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
]
),
.target(
name: "MCSMStream",
dependencies: ["MCSMAPI"]
),
.testTarget(
name: "MCSMStreamTests",
dependencies: [
"MCSMStream",
"MCSMAPI",
.product(name: "Testing", package: "swift-testing"),
]
),
.testTarget(
name: "MCSMAPITests",
dependencies: [
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ let text = try MCSMResponseEnvelope.checkedText(raw) // 或 unwrapText(raw)
| `GET /api/protected_instance/restart` | 同上 | 重启 |
| `GET /api/protected_instance/command` | 同上 | 命令走查询参数;`/` 开头是 MC 命令,不带 `/` 是控制台命令 |
| `GET /api/protected_instance/outputlog` | 同上 | 返回 `text/plain` 的**日志尾部**,可能从半行开始。体里仍是 `{status,data,time}` 外壳 → 用 `MCSMResponseEnvelope` 解 |
| `POST /api/protected_instance/stream_channel` | 同上 | 控制台**实时流**的第一步:拿 `addr` + 一次性口令,然后连那台 socket.io 发 `stream/auth`。见下节 |

### 实时流(`MCSMStream`,另一个 product)

轮询 `outputlog` 的实时性差;面板支持把节点 stdout 一条条推过来(面板自己的前端就是这么做的):

```swift
import MCSMStream

let channel = try await MCSMStreamChannel(client.getStreamChannel(query: .init(daemonId: d, uuid: u)))!
let endpoint = try MCSMStreamEndpoint(channel: channel, panelURL: panelURL)

let stream = MCSMStreamClient { endpoint }
for await event in await stream.events() {
switch event {
case .ready: break // stream/auth 通过,可以收输出了
case .output(let text): buffer.append(text)
case .fallback(let reason): await poll() // 实时流不可用 → 回退轮询
case .connecting, .lifecycle: break
}
}
```

要点(都是踩过的):

- **一次性口令**:每次重连都要重新申请通道 → 客户端收的是 `makeEndpoint` 闭包,不是固定 URL;
- **`addr` 可能是节点视角**(`127.0.0.1:24444`,面板 `dataPlane=direct` 时就是节点的公开地址):
回环地址会被换成本面板的主机名;连不上(节点在 NAT 后面)就回退轮询,这是正常路径;
- **`/socket.io/` 的尾部斜杠不能少**:少了会 404 / 握手失败(实测 `-1011 Websocket handshake failure`);
- **必须回 pong**:Engine.IO v4 由服务端 ping,不回会被 20~25 秒后踢掉;
- 事件参数形如 `{ "data": … }`,`instance/stdout` 的 `data.text` 是**原始终端文本**(带 ANSI 码、
可能半行、可能 `\r` 覆盖)→ 交给 `ConsoleTextParser` 那一套渲染,别自己按行切。

### 响应外壳(重要)

Expand Down Expand Up @@ -129,6 +161,21 @@ let text = try MCSMResponseEnvelope.checkedText(raw) // 或 unwrapText(raw)

它会拉官方 markdown,逐个检查 spec 里声明的路径还在不在(CI 里也有一个**不阻塞**的 job 跑它)。

## 真实面板冒烟(可选)

`stream_channel` 的形状与 socket.io 可达性只有真面板能验证,所以有一个默认跳过的测试:

```bash
ORZMC_MCSM_LIVE_URL=https://mcs.example.com \
ORZMC_MCSM_LIVE_KEY=<API Key> \
ORZMC_MCSM_LIVE_DAEMON=<daemonId> \
ORZMC_MCSM_LIVE_INSTANCE=<instanceUuid> \
swift test --filter LiveStreamTests
```

它会:申请通道 → 连 socket.io → 期望 `stream/auth` 回 `true` → 发一条 `/list` → 期望收到实时输出。
口令与实例**不要**写进仓库。

## 测试

```bash
Expand Down
126 changes: 126 additions & 0 deletions Sources/MCSMAPI/StreamChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// StreamChannel.swift
// MCSMAPI
//
// `POST /api/protected_instance/stream_channel` 的**业务视图**:把生成的响应模型
// (带/不带 `{status,data,time}` 外壳两种形状)抹平成"连哪、用什么口令"。
//
// 为什么需要这层:面板 direct / proxy 两种 `dataPlane` 返回的 `addr` 含义不同
// (节点地址 / 面板自身地址),但客户端接下来要做的事**完全一样** ——
// 连 `addr` + `prefix`,发 `stream/auth`。所以这里只做归一化,不做模式判断。
//

import Foundation

/// 一条控制台实时流通道。
public struct MCSMStreamChannel: Equatable, Sendable {

/// `host[:port]` 或 `ws(s)://host[:port][/path]`(原样,未解析)。
public let address: String
/// 一次性口令:direct 模式由节点签发,proxy 模式是面板签发的网关令牌。
public let password: String
/// socket.io 的路径前缀(proxy 模式为空;direct 模式跟随节点配置)。
public let prefix: String
/// 面板的地址重映射(用不上就空)。
public let remoteMappings: [MCSMRemoteMapping]

public init(address: String, password: String, prefix: String = "", remoteMappings: [MCSMRemoteMapping] = []) {
self.address = address
self.password = password
self.prefix = prefix
self.remoteMappings = remoteMappings
}
}

/// 面板的地址重映射:`from` 是浏览器看到的地址,`to` 是实际可连的地址。
public struct MCSMRemoteMapping: Equatable, Sendable {

public struct Endpoint: Equatable, Sendable {
public let address: String
public let prefix: String

public init(address: String, prefix: String = "") {
self.address = address
self.prefix = prefix
}
}

public let from: Endpoint
public let to: Endpoint

public init(from: Endpoint, to: Endpoint) {
self.from = from
self.to = to
}
}

extension MCSMStreamChannel {

/// 从 `POST /api/protected_instance/stream_channel` 的响应里取通道。
///
/// 面板 10.x 实际返回带外壳的形状;也兼容不套外壳(顶层直接有 `addr`/`password`)。
/// `addr` 或 `password` 缺失就返回 `nil`(说明面板版本太老,调用方回退轮询)。
public init?(_ response: Components.Schemas.StreamChannelResponse) {
if let payload = response.data,
let channel = Self(validating: payload.addr, password: payload.password,
prefix: payload.prefix ?? "",
remoteMappings: Self.mappings(payload.remoteMappings)) {
self = channel
return
}
guard let address = response.addr, let password = response.password,
let channel = Self(validating: address, password: password, prefix: response.prefix ?? "",
remoteMappings: Self.mappings(response.remoteMappings))
else { return nil }
self = channel
}

/// 纯文本响应体(面板有时用 `text/plain` 送同一份 JSON)也能解。
public init?(text: String) {
guard let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
// 先剥外壳,再看顶层。
let body = (object["data"] as? [String: Any]) ?? object
guard let address = body["addr"] as? String, let password = body["password"] as? String else {
return nil
}
guard let channel = Self(validating: address, password: password,
prefix: body["prefix"] as? String ?? "",
remoteMappings: MCSMRemoteMapping.parse(body["remoteMappings"]))
else { return nil }
self = channel
}

private init?(validating address: String, password: String, prefix: String, remoteMappings: [MCSMRemoteMapping]?) {
guard !address.isEmpty, !password.isEmpty else { return nil }
self.init(address: address, password: password, prefix: prefix, remoteMappings: remoteMappings ?? [])
}

private static func mappings(_ entries: [Components.Schemas.RemoteMappingEntry]?) -> [MCSMRemoteMapping] {
(entries ?? []).compactMap { entry in
guard let from = entry.from, let to = entry.to else { return nil }
return MCSMRemoteMapping(
from: .init(address: from.addr ?? "", prefix: from.prefix ?? ""),
to: .init(address: to.addr ?? "", prefix: to.prefix ?? "")
)
}
}
}

extension MCSMRemoteMapping {

/// 从任意 JSON 值解析(`init?(text:)` 用;形状不对就当没有)。
static func parse(_ value: Any?) -> [MCSMRemoteMapping] {
guard let array = value as? [[String: Any]] else { return [] }
return array.compactMap { entry in
guard let from = entry["from"] as? [String: Any], let to = entry["to"] as? [String: Any],
let fromAddr = from["addr"] as? String, let toAddr = to["addr"] as? String
else { return nil }
return MCSMRemoteMapping(
from: .init(address: fromAddr, prefix: from["prefix"] as? String ?? ""),
to: .init(address: toAddr, prefix: to["prefix"] as? String ?? "")
)
}
}
}
128 changes: 128 additions & 0 deletions Sources/MCSMAPI/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,68 @@ paths:
schema:
type: string

/api/protected_instance/stream_channel:
post:
operationId: getStreamChannel
summary: 申请控制台实时流通道(节点地址 + 一次性口令)
description: |
面板「实时终端」的第一步:拿到**往哪儿连**和**一次性口令**,然后连那个地址上的
socket.io,发 `stream/auth` 换到真正的输出流。比轮询 `outputlog` 实时得多
(面板内部就是把节点的 stdout 一条条推过来)。

返回体的形状取决于面板的 `dataPlane` 配置(面板 10.18+):

- `direct`(默认):`addr` 是**节点(守护进程)的地址**,客户端直连节点;
节点在 NAT 后面时这个地址从公网不可达 —— 连不上就回退轮询。
- `proxy`:`addr` 是**面板自己的对外地址**,`prefix` 为空,`password` 是面板签发的
网关令牌,由面板把 socket.io 中转给节点。公网部署通常用这个模式。

两种模式下**客户端代码一样**:连 `addr` + `prefix`,发 `stream/auth`。

**普通用户也能调用**(同 `/protected_instance` 一组,只要实例属于你)。
口令是一次性的、有有效期;断开重连要重新申请(本包不缓存)。
parameters:
- name: daemonId
in: query
required: true
schema:
type: string
- name: uuid
in: query
required: true
description: 实例 UUID(**不是**昵称)
schema:
type: string
responses:
"200":
description: 通道信息(带 `{status,data,time}` 外壳)
content:
application/json:
schema:
$ref: "#/components/schemas/StreamChannelResponse"
# 同其他接口:面板协议中间件会把体包成 JSON 但 Content-Type 有时还是 text/plain。
text/plain:
schema:
type: string
"403":
description: API Key 权限不足 / 未开启 API Key 功能 / 不是这台实例的主人
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
text/plain:
schema:
type: string
"500":
description: 面板内部错误 / 节点未连接
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
text/plain:
schema:
type: string

components:
securitySchemes:
apiKeyHeader:
Expand All @@ -472,6 +534,72 @@ components:
面板也接受 `?apikey=` 查询参数;本包用 header(不落 URL、不进日志)。

schemas:
StreamChannelResponse:
type: object
description: |
`POST /api/protected_instance/stream_channel` 的响应。

两种形状都要能解:带外壳 `{status, data: {…}, time}`(面板 10.x 实际行为),
以及不套外壳时直接把 `addr` / `password` 放在顶层。
properties:
status:
type: integer
time:
type: integer
format: int64
data:
$ref: "#/components/schemas/StreamChannelPayload"
addr:
type: string
password:
type: string
prefix:
type: string
remoteMappings:
type: array
items:
$ref: "#/components/schemas/RemoteMappingEntry"

StreamChannelPayload:
type: object
description: 通道本体:连哪、用什么口令。
required:
- addr
- password
properties:
addr:
type: string
description: |
`host[:port]` 或 `ws(s)://host[:port][/path]`。
`127.0.0.1` / `localhost` 开头的地址要换成本面板的主机名(节点和面板常在同一台机器)。
password:
type: string
description: 一次性口令(direct 模式是节点签发的,proxy 模式是面板签发的网关令牌)。
prefix:
type: string
description: socket.io 的路径前缀(proxy 模式为空;direct 模式跟随节点配置,如 `/daemon`)。
remoteMappings:
type: array
items:
$ref: "#/components/schemas/RemoteMappingEntry"

RemoteMappingEntry:
type: object
description: 面板的地址重映射(节点地址 → 浏览器可访问地址)。用不上时直接忽略。
properties:
from:
$ref: "#/components/schemas/RemoteMappingEndpoint"
to:
$ref: "#/components/schemas/RemoteMappingEndpoint"

RemoteMappingEndpoint:
type: object
properties:
addr:
type: string
prefix:
type: string

ErrorResponse:
type: object
required:
Expand Down
Loading
Loading