Add WebRTC signalling to the edge protocol
A browser cannot signal to a gateway's go2rtc directly. Measured against go2rtc 1.9.14: it sends no Access-Control-Allow-Origin on any response, the SDP POST needs a preflight anyway because application/sdp is not a CORS-safelisted content type, and it checks its API password before CORS - so the preflight, which a browser always sends without credentials, gets a 401. A gateway is behind a workshop NAT besides, so nothing can dial in. So the cloud relays the SDP exchange over the socket the gateway already holds open: webrtc_offer down, webrtc_answer or webrtc_error back up. Signalling only - no video on this socket, and that is the design rather than a limitation of it. Media goes browser-to-gateway through a TURN relay, DTLS-SRTP encrypted, so neither the backend nor the relay can read it. The alternative was tunnelling fMP4 up this socket and streaming it from the API, which would have meant plaintext video through the cloud and - since <video> cannot send an Authorization header - a credential in a URL, the pattern /ws/telemetry rejected. webrtc_error is its own variant rather than an empty answer because the cloud holds a browser's request open on this, and a silent failure there is a viewer watching a video element that never loads.
This commit is contained in:
@@ -209,6 +209,23 @@ export const EdgeClientMessageSchema = Type.Union([
|
|||||||
/** Only when `ok` is false. A message for an operator, not a code. */
|
/** Only when `ok` is false. A message for an operator, not a code. */
|
||||||
error: Maybe(Type.String()),
|
error: Maybe(Type.String()),
|
||||||
}),
|
}),
|
||||||
|
/** go2rtc's answer SDP for a `webrtc_offer`, passed through verbatim. */
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("webrtc_answer"),
|
||||||
|
session_id: Type.String(),
|
||||||
|
sdp: Type.String(),
|
||||||
|
}),
|
||||||
|
/**
|
||||||
|
* No answer was obtainable: no such camera, go2rtc not running, or it
|
||||||
|
* refused the offer. A variant of its own rather than an empty answer, so
|
||||||
|
* a viewer is told something instead of watching a `<video>` that never
|
||||||
|
* loads.
|
||||||
|
*/
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("webrtc_error"),
|
||||||
|
session_id: Type.String(),
|
||||||
|
message: Type.String(),
|
||||||
|
}),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
type: Type.Literal("frame_batch"),
|
type: Type.Literal("frame_batch"),
|
||||||
printer_id: Type.String(),
|
printer_id: Type.String(),
|
||||||
@@ -227,6 +244,39 @@ export const EdgeServerMessageSchema = Type.Union([
|
|||||||
Type.Object({ type: Type.Literal("hello_ack"), session_id: Type.String() }),
|
Type.Object({ type: Type.Literal("hello_ack"), session_id: Type.String() }),
|
||||||
Type.Object({ type: Type.Literal("hello_error"), message: Type.String() }),
|
Type.Object({ type: Type.Literal("hello_error"), message: Type.String() }),
|
||||||
Type.Object({ type: Type.Literal("heartbeat_ack"), seq: Type.Integer({ minimum: 0 }) }),
|
Type.Object({ type: Type.Literal("heartbeat_ack"), seq: Type.Integer({ minimum: 0 }) }),
|
||||||
|
/**
|
||||||
|
* Relay a browser's WebRTC offer to this gateway's go2rtc and send the
|
||||||
|
* answer back. **Signalling only - no video crosses this socket.** The
|
||||||
|
* gateway POSTs the offer to its own go2rtc over loopback, which go2rtc
|
||||||
|
* exempts from its API password, so no credential is needed or sent;
|
||||||
|
* browser and go2rtc then negotiate media directly over ICE.
|
||||||
|
*
|
||||||
|
* It exists because a browser cannot signal to a gateway itself. Measured
|
||||||
|
* against go2rtc 1.9.14: it sends no `Access-Control-Allow-Origin` on
|
||||||
|
* anything, and checks its password before CORS, so the unauthenticated
|
||||||
|
* preflight a browser always sends gets a 401.
|
||||||
|
*
|
||||||
|
* Relaying only the signalling is the point. The media stays
|
||||||
|
* peer-to-peer - on a workshop LAN it never leaves the building and costs
|
||||||
|
* no cloud egress - and go2rtc's answer carries a server-reflexive
|
||||||
|
* candidate as well as its host ones, so a remote viewer can often
|
||||||
|
* hole-punch straight to the gateway. A TURN server in go2rtc's
|
||||||
|
* `ice_servers` covers the NATs where that fails, and that is
|
||||||
|
* configuration rather than a change to this protocol.
|
||||||
|
*/
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal("webrtc_offer"),
|
||||||
|
/** Correlates the answer with this offer. One negotiation, not a subscription. */
|
||||||
|
session_id: Type.String(),
|
||||||
|
/**
|
||||||
|
* The go2rtc stream name, which is what `PrinterTelemetry.camera_stream`
|
||||||
|
* reported. Deliberately not called a printer id: the two are equal
|
||||||
|
* today, but one is the gateway's own naming and the other is ours.
|
||||||
|
*/
|
||||||
|
camera_stream: Type.String(),
|
||||||
|
/** The browser's offer SDP, passed through verbatim. */
|
||||||
|
sdp: Type.String(),
|
||||||
|
}, { additionalProperties: false }),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
type: Type.Literal("command"),
|
type: Type.Literal("command"),
|
||||||
command_id: Type.String(),
|
command_id: Type.String(),
|
||||||
|
|||||||
@@ -140,6 +140,48 @@
|
|||||||
"printer_id",
|
"printer_id",
|
||||||
"frames"
|
"frames"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "WebrtcAnswer",
|
||||||
|
"description": "go2rtc's answer SDP for a WebrtcOffer, passed through verbatim.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "webrtc_answer"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sdp": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id",
|
||||||
|
"sdp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "WebrtcError",
|
||||||
|
"description": "The gateway could not get an answer: no such camera, go2rtc not running, or it refused the offer. A variant of its own rather than an empty answer, so the viewer is told something instead of watching a video element that never loads.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "webrtc_error"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,48 +8,107 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HelloAck",
|
"title": "HelloAck",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "hello_ack" },
|
"type": {
|
||||||
"session_id": { "type": "string" }
|
"const": "hello_ack"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "session_id"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HelloError",
|
"title": "HelloError",
|
||||||
"description": "The handshake was rejected. The connection closes immediately after this.",
|
"description": "The handshake was rejected. The connection closes immediately after this.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "hello_error" },
|
"type": {
|
||||||
"message": { "type": "string" }
|
"const": "hello_error"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "message"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HeartbeatAck",
|
"title": "HeartbeatAck",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "heartbeat_ack" },
|
"type": {
|
||||||
|
"const": "heartbeat_ack"
|
||||||
|
},
|
||||||
"seq": {
|
"seq": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"description": "Echoes the seq from the heartbeat being acknowledged."
|
"description": "Echoes the seq from the heartbeat being acknowledged."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "seq"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"seq"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Command",
|
"title": "Command",
|
||||||
"description": "Do something to one printer on this gateway. Answered with a client command_result carrying the same command_id.",
|
"description": "Do something to one printer on this gateway. Answered with a client command_result carrying the same command_id.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": { "const": "command" },
|
"type": {
|
||||||
"command_id": { "type": "string" },
|
"const": "command"
|
||||||
|
},
|
||||||
|
"command_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"printer_id": {
|
"printer_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The gateway's own printer id, not the backend UUID - the backend translates on the way out, so a gateway never has to know its printers' cloud identities."
|
"description": "The gateway's own printer id, not the backend UUID - the backend translates on the way out, so a gateway never has to know its printers' cloud identities."
|
||||||
},
|
},
|
||||||
"command": { "$ref": "./printer_command.schema.json" }
|
"command": {
|
||||||
|
"$ref": "./printer_command.schema.json"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "command_id", "printer_id", "command"]
|
"required": [
|
||||||
|
"type",
|
||||||
|
"command_id",
|
||||||
|
"printer_id",
|
||||||
|
"command"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "WebrtcOffer",
|
||||||
|
"description": "Relay a browser's WebRTC offer to this gateway's go2rtc and send the answer back. SIGNALLING ONLY - no video crosses this socket. The gateway POSTs the offer to its own go2rtc over loopback (which go2rtc exempts from its API password, so no credential is needed or sent) and returns the answer as a WebrtcAnswer, after which the browser and go2rtc negotiate media directly over ICE. This exists because a browser cannot signal to a gateway itself: measured against go2rtc 1.9.14, it sends no CORS headers on anything and checks its password before CORS, so the unauthenticated preflight a browser always sends gets a 401. Relaying only the signalling keeps the media peer-to-peer - on a workshop LAN it never leaves the building, and go2rtc also offers a server-reflexive candidate, so a remote viewer can often hole-punch straight to the gateway. A TURN server in go2rtc's ice_servers is the fallback for the NATs where that fails; it is configuration, not a change to this protocol.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "webrtc_offer"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Correlates the answer with this offer. One negotiation, not a lasting subscription."
|
||||||
|
},
|
||||||
|
"camera_stream": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The go2rtc stream name, which is what PrinterTelemetry.camera_stream reported. Deliberately not called a printer id: the two are equal today, but one is the gateway's own naming and the other is the cloud's."
|
||||||
|
},
|
||||||
|
"sdp": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The browser's offer SDP, passed through verbatim."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"session_id",
|
||||||
|
"camera_stream",
|
||||||
|
"sdp"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user