73 KiB
SvxReflector protocol
Date: 2026-10-07
Scope: The TCP control and UDP audio protocol a station uses to join one SvxReflector, as implemented by upstream SvxLink. Two self-contained memos: Protocol Version 2.0 and Protocol Version 3.0. Shared byte encoding is specified once and cited by both. Protocol 1.x is a short historical section because this server still accepts it and still emits a distinct talker body.
Not this: A Go client; production FM-Funknetz credentials or a live probe of that reflector; reflector-to-reflector trunking; MQTT; talk-group mapping policy.
Upstream: GitHub sm0svx/svxlink master HEAD on 2026-10-07 was still commit fcd13014810af75d1852b3f2cfc8d9806178d0ab (2026-07-26, “Prepare for release 26.05.1”). In that commit src/versions: PROJECT=26.05.1, SVXLINK=1.10.1, SVXREFLECTOR=1.4.1. There is no IETF RFC. If this text and the cited source disagree, the source wins.
Fork check: dj1jay/jayReflector main on 2026-10-07 was still 50997ed21a37c7c05c132eca5ebb07bab4b80fc7. Client-facing TCP and UDP message types match upstream; see the note after the memos. The lab binary is now GeuReflector at dbad88e8c3e0f1adb8342db190bb0b3ad00106f4; the station message table in this file is unchanged.
This note is factual except sections labeled Inference.
How to read this file
Part A is the common encoding. Both memos use it normatively.
Part B is Protocol Version 2.0 (TYPE=ReflectorV2: HMAC-SHA1, plaintext UDP). An implementer of only version 2.0 reads Part A and Part B.
Part C is Protocol Version 3.0 (TYPE=Reflector: X.509, TLS on TCP, AES-128-GCM on UDP). An implementer of only version 3.0 reads Part A and Part C.
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as in RFC 2119. “A client MUST” means the server at this commit rejects or drops the session otherwise, or the stock client will not interoperate. “This server does” describes svxreflector at the pinned commit and is not a requirement on other servers.
Part A — Common encoding
A.1 Status of this part
Implementation-derived description of serialization at commit fcd13014810af75d1852b3f2cfc8d9806178d0ab. Not an IETF standard.
A.2 Protocol model
Roles:
| Role | What it is |
|---|---|
| Client | A station that opens TCP to one reflector and sends UDP audio. In this tree the clients are logic type ReflectorV2 (announces 2.0) and logic type Reflector (announces 3.0). |
| Reflector | The svxreflector process. It accepts many clients and forwards audio inside a talk group. |
| Talk group | A uint32 number. Audio from the current talker is copied to other clients that have selected that talk group, and talker indications are also sent to clients that monitor it. |
| Talker | At most one client per talk group whose audio is forwarded. The server assigns the talker from the first audio datagram on that talk group. |
One TCP connection is one client. The server assigns a uint16 client id in the range 1 through 65535, chosen with std::mt19937 and skipped forward if that id is in use [ReflectorClient.cpp newClientId, lines 396–407].
A.3 Byte order and primitive types
All multi-byte integers are big-endian. Async::Msg packs them with htobe16, htobe32, and htobe64, and unpacks with be16toh, be32toh, and be64toh [AsyncMsg.h Packer16 / Packer32 / Packer64, lines 286–385].
| Type in the C++ message | On the wire |
|---|---|
uint8_t, int8_t, char |
1 byte, no endian swap |
uint16_t, int16_t |
2 bytes, big-endian |
uint32_t, int32_t |
4 bytes, big-endian |
uint64_t, int64_t |
8 bytes, big-endian |
float |
4 bytes, big-endian bit pattern (same overlay as uint32_t) |
double |
8 bytes, big-endian bit pattern (same overlay as uint64_t) |
std::string |
uint16 byte count, then that many bytes. The count is val.size(). No trailing NUL is included. A string longer than 65535 bytes fails to pack. Embedded NUL bytes are data. |
std::vector<T> |
uint16 element count, then each element packed as T. A vector<uint8_t> is therefore a uint16 length and then raw bytes. |
std::set<T> |
uint16 element count, then each element in set order. For set<uint32_t> that order is ascending numeric order. |
std::array<T,N> and T[N] |
N elements, no count prefix |
[AsyncMsg.h MsgPacker<std::string> lines 407–445; MsgPacker<std::vector> lines 447–496; MsgPacker<std::set> lines 498–555.]
A derived message class does not pack its base class unless it uses ASYNC_MSG_DERIVED_FROM. The reflector messages do not. The sender writes the type with a separate ReflectorMsg or ReflectorUdpMsg / ReflectorUdpMsgV2 header, then packs the derived object, which writes only its own members [ReflectorClient.cpp sendMsg lines 272–288; ReflectorV2Logic.cpp sendMsg lines 1298–1328]. ASYNC_MSG_NO_MEMBERS therefore means a body of zero bytes after the type header.
A.4 TCP framing
TCP carries a sequence of frames. Each frame is:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| payload length (32 bits) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
+ payload +
| (length bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The length is the number of payload bytes and does not include itself. It is written as four big-endian bytes [AsyncFramedTcpConnection.h QueueItem, lines 292–307]. The receiver closes the connection with disconnect reason protocol error if the length is greater than its current maximum receive size [AsyncFramedTcpConnection.cpp lines 262–266].
The payload is one reflector message:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| message type (16) | message body ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The type is a uint16. The body is the packed members of that message class and nothing else.
Maximum payload sizes enforced by the reflector messages [ReflectorMsg.h lines 110–112]:
| Constant | Bytes | When this server applies it |
|---|---|---|
MAX_PREAUTH_FRAME_SIZE |
64 | Receive limit from TCP accept until protocol 3 starts TLS, and the version 2.0 client’s limit until MsgAuthOk |
MAX_SSL_SETUP_FRAME_SIZE |
4096 | Server receive limit after it has sent MsgStartEncryption, until authentication completes |
MAX_POSTAUTH_FRAME_SIZE |
32768 | Server transmit limit for the whole session. Server receive limit after authentication. Version 3.0 client limit for the whole session. Version 2.0 client limit after MsgAuthOk. |
The framing class’s own default, before those calls, is 1 MiB [AsyncFramedTcpConnection.h line 290].
Both sides unpack the type first. A payload shorter than 2 bytes fails that unpack. The version 2.0 client then disconnects. This server sends MsgError with "Protocol error" [ReflectorClient.cpp lines 506–513; ReflectorV2Logic.cpp lines 843–849].
Unknown type numbers are ignored after the type has been unpacked. Ignoring them still counts as traffic for the TCP receive timer. Application types (type ≥ 100) received before the authenticated state are specified in each memo.
A.5 TCP message catalogue
Type numbers are the template argument of ReflectorMsgBase<N> in ReflectorMsg.h. Direction is who sends the message in this tree. “Either” means the class is handled on both sides.
| Type | Class | Body after the type | Direction |
|---|---|---|---|
| 1 | MsgHeartbeat |
empty | Both |
| 5 | MsgProtoVer |
uint16 major, uint16 minor |
Client, first message |
| 6 | MsgProtoVerDowngrade |
uint16 major, uint16 minor |
Server |
| 10 | MsgAuthChallenge |
vector<uint8_t> of 20 bytes |
Server, major < 3, and one protocol 3 fallback |
| 11 | MsgAuthResponse |
string callsign, then vector<uint8_t> digest |
Client |
| 12 | MsgAuthOk |
empty | Server |
| 13 | MsgError |
string message |
Either. Receiver drops the connection. |
| 14 | MsgStartEncryptionRequest |
empty | Client, major ≥ 3 |
| 15 | MsgStartEncryption |
empty | Server, major ≥ 3 |
| 16 | MsgClientCsrRequest |
empty | Server, major ≥ 3 |
| 17 | MsgClientCsr |
string PEM |
Client, major ≥ 3 |
| 18 | MsgClientCert |
string PEM |
Server, major ≥ 3 |
| 19 | MsgCAInfo |
uint16 PEM size, vector<uint8_t> SHA-256 |
Server, major ≥ 3 |
| 20 | MsgCABundleRequest |
empty | Client, major ≥ 3 |
| 21 | MsgCABundle |
string CA PEM, vector<uint8_t> signature, string signing-certificate PEM |
Server, major ≥ 3 |
| 100 | MsgServerInfo |
uint16 reserved, uint16 client id, vector<string> nodes, vector<string> codecs |
Server, after login |
| 101 | MsgNodeList |
vector<string> callsigns |
Server, only if client version < 0.7 |
| 102 | MsgNodeJoined |
string callsign |
Server to other clients |
| 103 | MsgNodeLeft |
string callsign |
Server to other clients |
| 104 | MsgTalkerStart |
uint32 talk group, string callsign |
Server to clients with major ≥ 2 |
| 104 | MsgTalkerStartV1 |
string callsign |
Server to clients with major = 1. Same type number, shorter body. |
| 105 | MsgTalkerStop |
uint32 talk group, string callsign |
Server to clients with major ≥ 2 |
| 105 | MsgTalkerStopV1 |
string callsign |
Server to clients with major = 1 |
| 106 | MsgSelectTG |
uint32 talk group |
Client |
| 107 | MsgTgMonitor |
set<uint32> talk groups |
Client |
| 108 | — | Not compiled. The old site-list MsgNodeInfo is inside #if 0 [ReflectorMsg.h lines 944–1136]. |
— |
| 109 | MsgRequestQsy |
uint32 talk group. Zero means “server chooses”. |
Client request; server fan-out to major ≥ 2 on that talk group |
| 110 | MsgStateEvent |
string src, string name, string msg |
Client. This server logs it and does not fan it out. The stock clients’ send call is inside a comment. |
| 111 | MsgNodeInfo |
vector<uint8_t> IV random, vector<uint8_t> UDP key, string JSON |
Client with major ≥ 3 |
| 111 | MsgNodeInfoV2 |
string JSON |
Client with major < 3. Same type number. |
| 112 | MsgSignalStrengthValues |
vector of receiver records |
Client |
| 113 | MsgTxStatus |
vector of transmitter records |
Client |
| 114 | MsgStartUdpEncryption |
empty | Server, major ≥ 3 |
MsgAuthChallenge::LENGTH is 20. The body is a vector, so the wire form is uint16 20 followed by 20 bytes from RAND_bytes. If RAND_bytes fails, the vector is cleared and this server disconnects without sending the message [ReflectorMsg.h lines 366–381; ReflectorClient.cpp sendAuthChallenge lines 1401–1412].
MsgAuthResponse member order in the pack macro is callsign, then digest, even though the fields are declared in the other order [ReflectorMsg.h line 464]. DIGEST_LEN is 20. The digest vector is uint16 20 followed by 20 bytes. How those 20 bytes are computed is in Part B.
MsgServerInfo always writes m_reserved, which this server leaves at 0 [ReflectorMsg.h lines 720–724]. The node list is filled after construction and includes every connected client that already has a callsign, including the client being welcomed, because the callsign is stored before nodeList() [ReflectorClient.cpp lines 1344–1358; Reflector.cpp nodeList lines 377–388]. Codec names are a vector; this server puts one name in it.
Receiver record inside type 112, and the same record inside UDP type 104 [ReflectorMsg.h lines 1270–1302]:
0 1 2
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| id (char) | siglev (uint8)| flags |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Flag bits, from bit 0: enabled, squelch open, active. The C++ constructor parameter is int16_t, and the stock client clamps the value to 0..100, but the packed field is uint8_t. A vector of these records is prefixed with a uint16 count.
Transmitter record inside type 113 [ReflectorMsg.h lines 1341–1368]: char id, then uint8 flags. Bit 0 means transmitting. A uint16 count precedes the records.
Type 110’s three strings are independent length-prefixed strings.
A.6 UDP message types
These type numbers live in a different namespace from the TCP types. They are the template argument of ReflectorUdpMsgBase<N>.
| Type | Class | Body after the UDP header of the version in use | Direction |
|---|---|---|---|
| 1 | MsgUdpHeartbeat |
empty | Both |
| 101 | MsgUdpAudio |
vector<uint8_t> encoded audio. No talk-group field. A variant that included a uint32 talk group is inside #if 0 and is not compiled [ReflectorMsg.h lines 1423–1444 and 1586–1618]. |
Both |
| 102 | MsgUdpFlushSamples |
empty | Both. “End of this audio stream.” |
| 103 | MsgUdpAllSamplesFlushed |
empty | Both. “The samples have been written to the local audio sink.” |
| 104 | MsgUdpSignalStrengthValues |
same receiver-record vector as TCP type 112 | Client. This server accepts it. The stock client sends TCP type 112 instead; its UDP send is commented out. |
The header in front of that body depends on the major version. Part B specifies the version 2 header. Part C specifies the version 3 header and the cipher.
One audio datagram carries one encoder callback: the bytes from a single writeEncodedSamples call, stored in the vector. An empty vector is legal and is not passed to the decoder.
A.7 Addresses
This server binds TCP and UDP to the same LISTEN_PORT. The code default, if the configuration key is absent, is the string "5300" for TCP and the integer 5300 for UDP [Reflector.cpp lines 284–300]. The shipped svxreflector.conf.in sets LISTEN_PORT=5300. The man page says clients do not open inbound ports [svxreflector.conf.5 LISTEN_PORT].
The client sends UDP to remoteHost() and remotePort() of the TCP connection, which are the reflector’s address and that same port [ReflectorV2Logic.cpp sendUdpMsg lines 1487–1488]. The client’s UDP socket is constructed with local port 0, so the kernel chooses an ephemeral port [AsyncUdpSocket.h line 134; ReflectorV2Logic.cpp line 1055].
This server learns that ephemeral port from the first acceptable UDP datagram and then requires later datagrams to use it [Reflector.cpp lines 1095–1107]. Until that datagram arrives, the server has no UDP destination and its UDP sends return immediately [ReflectorClient.cpp sendUdpMsg lines 323–328].
DNS, when the client is configured with DNS_DOMAIN, is a lookup of SRV service svxreflector / tcp [ReflectorV2Logic.cpp line 223; ReflectorLogic.cpp line 260]. The UDP port is the TCP SRV port. There is no separate UDP SRV name in this client.
A.8 Version admission
MsgProtoVer is the first TCP payload the client sends. The class default is major 3, minor 0. The version 2.0 client constructs (2, 0) [ReflectorV2Logic.cpp line 127].
This server accepts a version that is greater than or equal to 0.6 and less than or equal to 3.0. Comparison is major, then minor [ProtoVer.h operator< lines 145–149; ReflectorClient.h lines 509–510; ReflectorClient.cpp handleMsgProtoVer lines 607–660].
| Announced version | This server |
|---|---|
| Greater than 3.0, and this is the first version message | Sends MsgProtoVerDowngrade with major 3, minor 0, and stays in the expect-version state |
| Greater than 3.0 again | MsgError "Unsupported protocol version M.N. May be at most 3.0." |
| Less than 0.6 | MsgError "Unsupported protocol version M.N. Must be at least 0.6." |
| Major ≥ 3, within range | Part C |
| Major < 3, within range | MsgAuthChallenge, then Part B’s authentication, with the version-dependent body variants in A.5 |
ProtoVer::operator!= compares the major number only [ProtoVer.h lines 141–144]. The admission tests do not use it.
A second MsgProtoVer outside the expect-version state produces MsgError "Protocol version expected".
A.9 Callsign grammar
This server applies callsignOk to the authentication callsign and to a protocol 3 certificate common name [Reflector.cpp lines 650–701].
The callsign MUST be non-empty. It MUST match ACCEPT_CALLSIGN as a full std::regex_match. If that key is missing or empty, the pattern compiled into the server is:
[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?
std::regex with no flags uses the ECMAScript grammar. The match is case-sensitive. This server does not rewrite case. A callsign that fails the pattern produces MsgError "Invalid callsign".
If REJECT_CALLSIGN is set and the callsign matches it, the callsign is rejected the same way. The default configuration sets no reject pattern [svxreflector.conf.5].
A second simultaneous session with the same callsign produces MsgError "Access denied" [ReflectorClient.cpp lines 1336–1377].
A.10 Codec names
MsgServerInfo carries codec names as strings. This server’s CODECS value is split on commas, and only the first name is kept. If CODECS is unset, the name is OPUS when Opus encode and decode are both compiled in, otherwise SPEEX when Speex is compiled in, otherwise GSM [ReflectorClient.cpp lines 214–239].
Names the audio library recognizes, when the corresponding library is compiled in [AsyncAudioEncoder.cpp isAvailable lines 123–134]: NULL, RAW, S16, GSM, SPEEX, OPUS, DUMMY. The man page lists OPUS, SPEEX, GSM, S16 (signed 16-bit), and RAW (32-bit float) [svxreflector.conf.5 CODECS].
The client walks the server’s list and uses the first name for which both an encoder and a decoder exist. The selected name is not echoed. The audio datagram does not repeat it. Opus parameters are in each memo’s audio section; both clients use the same encoder class.
A.11 Talk groups, talker, and QSY
MsgSelectTG replaces the client’s current talk group. If the number differs from the current one, and this client is the talker, this server sends MsgUdpFlushSamples to the other clients on that talk group. If someone else is the talker, this server sends MsgUdpFlushSamples to the client that is leaving. There is no TCP acknowledgement. If the section TG#<n> has ALLOW and the callsign does not match that regular expression, or if the expression is invalid, this server forces the client to talk group 0 and does not tell the client [ReflectorClient.cpp handleSelectTG lines 855–881 and setTg lines 1452–1466; TGHandler.cpp allowTgSelection lines 266–289]. The source comment marks the missing notification as a FIXME.
MsgTgMonitor replaces the idle-monitor set. This server drops talk group 0 and any talk group that fails ALLOW or ALLOW_MONITOR. It does not send an error for the dropped numbers [ReflectorClient.cpp lines 885–913].
Audio is forwarded only when the client’s selected talk group is non-zero, the client is not in a talker block, and the client is the talker for that talk group. The first acceptable audio datagram makes that client the talker [Reflector.cpp lines 1159–1192].
While a client remains the talker, each forwarded audio datagram refreshes the talker timestamp. A periodic 1-second timer clears the talker when diff.tv_sec > 3 after the last refresh (TALKER_AUDIO_TIMEOUT is 3) [TGHandler.h line 180; TGHandler.cpp lines 363–371]. Clearing the talker sends TCP talker-stop and UDP flush as below.
On talker start, this server sends MsgTalkerStart (type 104, with talk group) to clients whose version is ≥ 2.0 and that have selected or monitor that talk group. On talker stop it sends MsgTalkerStop to the same filter, and MsgUdpFlushSamples to the other clients who have selected that talk group. Clients whose major version is 1, and only when the talk group equals TG_FOR_V1_CLIENTS, receive MsgTalkerStartV1 / MsgTalkerStopV1 (type 104 / 105, callsign only) [Reflector.cpp onTalkerUpdated lines 1297–1333]. The class comments say UDP audio may arrive before talker-start and after talker-stop, and that the TCP messages are informational [ReflectorMsg.h lines 797–805 and 848–856].
TG_FOR_V1_CLIENTS in the shipped configuration template is 999. If the key is absent, the binary member is initialized to 1 [Reflector.cpp line 232; svxreflector.conf.in line 14].
MsgRequestQsy from a client whose current talk group is 0 is ignored. Otherwise this server broadcasts MsgRequestQsy to version ≥ 2.0 clients that have selected the requester’s current talk group, including the requester. If the requested talk group is 0, the server walks RANDOM_QSY_RANGE for an empty talk group. If that range was not configured, it sends nothing [Reflector.cpp requestQsy lines 459–482 and nextRandomQsyTg lines 1409–1432]. The range format in the man page is lower:count.
SQL_TIMEOUT defaults to 0, which disables the long-transmission block. When it is non-zero, the 1-second timer counts it down while the client is talker and then calls setBlock for SQL_TIMEOUT_BLOCKTIME seconds. The code default for that block is 60, and the value is raised to at least 1 [Reflector.cpp lines 317–323; TGHandler.cpp lines 131–133 and 374–379]. The man page says the block default is one second. The code path above is what this binary does when the key is absent.
A blocked client’s UDP audio and UDP signal-strength datagrams are discarded. Other UDP types are still accepted. Receiving audio restarts the block timer if a block is already set [ReflectorClient.cpp lines 316–319].
A.12 Heartbeat timers shared by both clients
Both stock clients use a periodic timer of 1000 ms, started when TCP connects [ReflectorV2Logic.cpp line 142; ReflectorLogic.cpp line 174].
| Counter | Reset value | Effect when it reaches zero |
|---|---|---|
| TCP transmit | 10 | Send MsgHeartbeat |
| TCP receive | 15 | Client disconnects |
| UDP transmit | 15, or UDP_HEARTBEAT_INTERVAL if set |
Send a UDP heartbeat (version 3 retransmits registration while waiting; see Part C) |
| UDP receive | 60 | Client disconnects |
Any successful TCP send resets the TCP transmit counter. Any unpacked TCP frame resets the TCP receive counter. A successfully sequenced UDP datagram resets the UDP receive counter. These counters start at TCP connect, so the UDP receive limit is running before the UDP socket exists.
This server’s timer also fires every 1000 ms, and it is enabled when the client object is constructed, which is at TCP accept [ReflectorClient.cpp lines 188–191; AsyncTimer.h line 142 default enabled=true].
| Server counter | Reset | Effect at zero |
|---|---|---|
| TCP transmit | 10 | MsgHeartbeat |
| TCP receive | 15 | MsgError "TCP heartbeat timeout", then close |
| UDP transmit | 15 | MsgUdpHeartbeat, if a UDP destination is known |
| UDP receive | 120 | MsgError "UDP heartbeat timeout", then close |
[ReflectorClient.h lines 512–515; ReflectorClient.cpp handleHeartbeat lines 1259–1298.]
MsgError disables the server heartbeat timer, sends the error, and arms a one-shot disconnect timer of 10000 ms [ReflectorClient.cpp lines 1210–1226]. The client closes as soon as it unpacks MsgError.
A.13 Historical protocol 1
This commit has no ReflectorV1 client. The server still accepts major version 1, provided the minor version is at least 6 when the major is 0, and any minor when the major is 1, up through 3.0.
What is distinct for major version 1:
- UDP uses the version 2 header in Part B, because the encrypted header starts at major 3.
- Authentication is the HMAC challenge in Part B.
- After login the server sets the talk group to
TG_FOR_V1_CLIENTS. The client cannot rely on its ownMsgSelectTGstaying in force; the next server-sidesetTgfor that client uses the forced value at login [ReflectorClient.cpp lines 1366–1369]. - Talker TCP messages are types 104 and 105 with a callsign and no talk-group field.
MsgRequestQsyis not sent to these clients.- If the version is below 0.7,
MsgServerInfois followed byMsgNodeList(type 101) carrying the same callsign vector.
Major 0, minor ≥ 6, is also on the challenge path and the version 2 UDP header. It is not in the “version 1” talker filter (ProtoVer(1,0) through ProtoVer(1,999)) and it is not in the “≥ 2.0” filter, so this server does not send it talker-start or talker-stop. It can still send MsgSelectTG. MsgNodeList is sent when the version is below 0.7 [Reflector.cpp lines 195–200 and 1360–1364].
A.14 References for Part A
Blob prefix: https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/
| Topic | Symbol | Path |
|---|---|---|
| Integer, string, vector, set packing | MsgPacker, ASYNC_MSG_MEMBERS |
src/async/core/AsyncMsg.h |
| TCP length prefix and max frame | FramedTcpConnection::QueueItem, onDataReceived |
src/async/core/AsyncFramedTcpConnection.h, .cpp |
| Type numbers and bodies | ReflectorMsgBase, ReflectorUdpMsgBase |
src/svxlink/reflector/ReflectorMsg.h |
| Header then body | ReflectorClient::sendMsg |
src/svxlink/reflector/ReflectorClient.cpp |
| Version limits | MIN_MAJOR_VER, handleMsgProtoVer |
src/svxlink/reflector/ReflectorClient.h, .cpp |
| Version comparison | ProtoVer::operator< |
src/svxlink/reflector/ProtoVer.h |
| Ports | Reflector::initialize |
src/svxlink/reflector/Reflector.cpp |
| Callsign | Reflector::callsignOk |
src/svxlink/reflector/Reflector.cpp |
| Codec list | ReflectorClient constructor |
src/svxlink/reflector/ReflectorClient.cpp |
| Talker and QSY | onTalkerUpdated, requestQsy |
src/svxlink/reflector/Reflector.cpp |
| Talker timeout | TALKER_AUDIO_TIMEOUT, checkTimers |
src/svxlink/reflector/TGHandler.h, TGHandler.cpp |
| Man-page defaults | LISTEN_PORT, CODECS, ACCEPT_CALLSIGN |
src/doc/man/svxreflector.conf.5 |
Part B — SvxReflector Protocol Version 2.0
B.1 Status of this memo
Implementation-derived description of the version 2.0 session at commit fcd13014810af75d1852b3f2cfc8d9806178d0ab. Not an IETF standard. Encoding rules, the message catalogue, callsign grammar, talk-group rules, and the server timers are Part A. This memo does not use the certificate or UDP-cipher procedures in Part C.
B.2 Abstract
A client opens one TCP connection, sends protocol version 2.0, proves knowledge of a shared key with HMAC-SHA1 over a server nonce, receives a client id and a codec name, then exchanges plaintext UDP datagrams with that reflector on the same host and port. Audio for a talk group has a single talker. The UDP datagrams are not encrypted and are not authenticated beyond the source address and the client id.
B.3 Table of contents
- B.4 Introduction
- B.5 Terminology
- B.6 Transport
- B.7 UDP header
- B.8 Session
- B.9 State diagram
- B.10 Timing
- B.11 Audio
- B.12 Security considerations
- B.13 Differences from version 3.0
- B.14 References
- B.15 Unresolved
B.4 Introduction
Version 2.0 is the session spoken by logic type ReflectorV2. On TCP connect that client sends MsgProtoVer with major 2 and minor 0 [ReflectorV2Logic.cpp lines 127 and 785–801]. FM-Funknetz’s public instruction to set TYPE=ReflectorV2 selects this session. Whether a given production reflector still accepts it was not measured for this memo.
A client that only implements this memo MUST send major 2 (or another major less than 3 that this server accepts) and MUST implement the HMAC response. Sending major 3 selects Part C on this server.
B.5 Terminology
The Part A keyword rule applies.
A client MUST send version 2.0 as its first TCP payload if it wants this session on this server. This server answers major values less than 3, down to 0.6, with the same HMAC exchange. Major 1 additionally follows Part A.13.
This server checks the HMAC. A client that sends a wrong digest receives MsgError "Access denied" and is not logged in.
B.6 Transport
The client MUST connect TCP to the reflector. The stock client requires HOSTS or DNS_DOMAIN [ReflectorV2Logic.cpp lines 213–223]. UDP is sent to the TCP peer’s address and port, from an ephemeral local port, as in Part A.7.
Until MsgAuthOk, the stock client sets the maximum TCP payload to 64 bytes [ReflectorV2Logic.cpp line 801]. A version 2.0 MsgAuthResponse for a callsign that matches the default grammar fits: type (2) + callsign (2 + at most 12) + digest (2 + 20) is at most 38 bytes. After MsgAuthOk the client raises the maximum to 32768 [ReflectorV2Logic.cpp line 986].
This server’s receive maximum stays 64 until authentication, then becomes 32768. Its transmit maximum is 32768 from accept [ReflectorClient.cpp lines 199–200 and 1343].
B.7 UDP header
For every major version less than 3, including 2.0, each UDP datagram is:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| type (16) | client id (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| sequence (16) | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +
| message body |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
type, client id, and sequence are big-endian uint16 [ReflectorUdpMsgV2, ReflectorMsg.h lines 210–257]. The body is A.6. The client id is the value from MsgServerInfo. The sender increments a uint16 sequence once per datagram it transmits, starting at 0 when the TCP connection is established [ReflectorV2Logic.cpp lines 797–798 and 1479].
The receiver computes uint16 diff = received - expected, wrapping modulo 65536.
- If
diff > 32767, the datagram is old. The stock client and this server drop it and do not advance the expected sequence. - If
diffis 1 or more and at most 32767, the datagram is accepted and the expected sequence becomesreceived + 1. Lost numbers are not retransmitted. - If
diffis 0, the datagram is the expected one and the expected sequence becomesreceived + 1.
[ReflectorV2Logic.cpp lines 1400–1417; Reflector.cpp lines 1129–1147.]
The stock client also drops a datagram whose source address is not the TCP peer, whose source port is not the TCP peer’s port, or whose client id is not the id from MsgServerInfo [ReflectorV2Logic.cpp lines 1366–1398].
This server, for a version less than 3, looks up the client by the header’s client id, drops the datagram if the source address is not that client’s TCP address, remembers the source port from the first accepted datagram, and then requires that port [Reflector.cpp lines 1056–1107]. The lookup does not require the datagram to be encrypted. sendUdpDatagram for version less than 3 writes the version 2 header and calls the plaintext UDP write. The sequence it writes is udpCipherIVCntrNext() & 0xffff, a uint32 counter that starts at 0 and is post-incremented, truncated to 16 bits [Reflector.cpp lines 431–439; ReflectorClient.h line 481].
B.8 Session
The stock client states that are actually assigned are: disconnected, expect authentication challenge, expect authentication ok, expect server info, connected [ReflectorV2Logic.h lines 198–202]. STATE_AUTHENTICATED sits in the enum and is not assigned; server-info handling requires the expect-server-info state, which is already past it, so node information is allowed.
B.8.1 Connect and version
- The client opens TCP. On connect it sends
MsgProtoVer(2, 0), zeros both UDP sequences, starts the 1-second timer, and waits for a challenge [ReflectorV2Logic.cpp lines 785–801]. - This server, for major 2, sends
MsgAuthChallenge.
If the server sends MsgProtoVerDowngrade, this client prints that it cannot downgrade and disconnects [ReflectorV2Logic.cpp lines 926–940]. A client that wants to stay on 2.0 MUST treat a downgrade request as fatal or MUST send a new MsgProtoVer no higher than the server’s number and then speak only that version. This particular client does the former. This server will accept a following MsgProtoVer(2, 0).
B.8.2 Authentication
The challenge body is 20 random bytes (Part A.5). The client rejects a challenge whose vector length is not 20 [ReflectorV2Logic.cpp lines 962–968].
The client computes the digest as follows [ReflectorMsg.h calcHMAC lines 470–484]:
- The key is the
AUTH_KEYstring’s raw bytes. The stock client refuses to start ifAUTH_KEYis missing, empty, or the placeholderChange this key now![ReflectorV2Logic.cpp lines 263–274]. The key is not hex-decoded and is not sent on the wire. - The MAC is HMAC-SHA1: OpenSSL
EVP_PKEY_HMACvianewRawPrivateKey, thensignInit("sha1"), over exactly the 20 challenge bytes (MsgAuthChallenge::LENGTH). The callsign is not an input to the MAC. - The MAC output is 20 bytes.
The client sends MsgAuthResponse: the callsign string, then the digest vector. The callsign is the configured string, without case conversion [ReflectorV2Logic.cpp line 970].
This server then:
- Requires state expect-auth-response. Otherwise
MsgError"Authentication response unexpected". - Requires the body to unpack. Otherwise
"Illegal MsgAuthResponse protocol message received". - Requires
callsignOk(Part A.9). Otherwise"Invalid callsign". - Looks up
[USERS]for that exact callsign to get a password-group name, then[PASSWORDS]for that group to get the key string. It recomputes HMAC-SHA1 and compares withCRYPTO_memcmp. Empty key or a mismatch yields"Access denied"[ReflectorClient.cpp lines 718–781 andlookupUserKeylines 1315–1333]. - If the callsign is already in the connected-callsign map,
"Access denied".
On success this server sets the receive frame maximum to 32768, sends MsgAuthOk, sends MsgServerInfo, sends MsgNodeList only when the version is < 0.7, forces the talk group when the version is < 2.0, and broadcasts MsgNodeJoined to the other clients [ReflectorClient.cpp lines 1336–1371].
B.8.3 Server info, codec, node info, talk group
The client accepts MsgServerInfo only in the expect-server-info state. It stores the client id. It selects the first codec name it can both encode and decode (Part A.10). If none match, it logs that and continues; it does not disconnect. The encoder remains the DUMMY encoder installed at startup, so it will not emit audio [ReflectorV2Logic.cpp lines 990–1052 and 341].
It then builds a single-line JSON object and sends it as MsgNodeInfoV2 (type 111, one string). The stock object contains at least:
| Key | Value |
|---|---|
sw |
"SvxLink" |
swVer |
the SvxLink application version string |
projVer |
the project version string |
If NODE_INFO_FILE is set, that JSON is parsed first and those three keys are then overwritten. Version 2.0 does not add machineArch [ReflectorV2Logic.cpp lines 425–460 and 1061–1069].
A client SHOULD send type 111 in the version 2 layout (a single string). This server selects the layout from the announced major version. A major below 3 that sends the version 3 layout fails the unpack and receives "Illegal MsgNodeInfo protocol message received" [ReflectorClient.cpp lines 917–950]. A JSON value this server cannot parse produces a warning and does not drop the connection. No JSON keys are required for audio.
If the client’s configured talk group is non-zero, it sends MsgSelectTG. If it has monitor talk groups, it sends MsgTgMonitor. It then sends a UDP heartbeat [ReflectorV2Logic.cpp lines 1117–1128].
MsgSelectTG and MsgTgMonitor may be sent again later. The stock client also sends MsgRequestQsy when its local command logic asks for a QSY [ReflectorV2Logic.cpp lines 516–525]. A client that never sends type 109 is accepted.
B.8.4 Audio, flush, and talker indications
The client sends MsgUdpAudio when the encoder emits a frame, and MsgUdpFlushSamples when the encoder finishes a transmission. It arms a 3000 ms one-shot timer on flush. If that timer fires, it tells the encoder that all samples are flushed, which causes MsgUdpAllSamplesFlushed [ReflectorV2Logic.cpp lines 1331–1355 and 1529–1538].
This server, on MsgUdpFlushSamples from the current talker, clears the talker and immediately sends MsgUdpAllSamplesFlushed back to that client. It does not wait for the other clients. The source comment says waiting would not scale [Reflector.cpp lines 1232–1250]. Incoming MsgUdpAllSamplesFlushed is ignored by the server. The client uses it to complete the local encoder flush [ReflectorV2Logic.cpp lines 1449–1451].
MsgTalkerStart for version 2.0 includes the talk group. The stock client, if it is idle, selects that talk group. If it is using monitor priorities and the new talk group has a higher priority, it switches. It does not drop the TCP session when it ignores a talker message [ReflectorV2Logic.cpp lines 1192–1234]. A client that has already selected one talk group and does not want to follow other talk groups MUST still accept the message, and MAY ignore it. Audio does not wait for the TCP message: the client writes any non-empty audio payload from an accepted UDP datagram into the decoder [ReflectorV2Logic.cpp lines 1426–1441].
MsgTalkerStop is logged and emitted as a local event. The stock client does not treat it as the end of audio by itself. The decoder is flushed by MsgUdpFlushSamples, or by the local 3-second gap rule in B.10.
MsgNodeJoined and MsgNodeLeft are informational. A failed unpack disconnects the stock client.
B.8.5 Errors and disconnect
This client disconnects on MsgError, on a message that fails to unpack, on an unexpected challenge, authentication-ok, or server-info, and on a TCP or UDP heartbeat timeout [ReflectorV2Logic.cpp handleTimerTick lines 1542–1576]. It does not send its own MsgError in those paths.
This server’s MsgError strings for this session are:
| String | Condition |
|---|---|
Protocol error |
TCP type field failed to unpack |
Protocol version expected |
MsgProtoVer outside the expect-version state |
Illegal MsgProtoVer protocol message received |
Version body failed to unpack |
Unsupported protocol version M.N. May be at most 3.0. |
Version above 3.0 after a downgrade was already offered |
Unsupported protocol version M.N. Must be at least 0.6. |
Version below 0.6 |
Authentication response unexpected |
Response in the wrong state |
Illegal MsgAuthResponse protocol message received |
Response body failed to unpack |
Invalid callsign |
callsignOk failed |
Access denied |
Unknown user, bad HMAC, or callsign already connected |
Illegal MsgSelectTG protocol message received |
Body failed to unpack |
Illegal MsgTgMonitor protocol message received |
Body failed to unpack |
Illegal MsgNodeInfo protocol message received |
Body failed to unpack |
Illegal MsgRequestQsy protocol message received |
Body failed to unpack |
Illegal MsgStateEvent protocol message received |
Body failed to unpack |
TCP heartbeat timeout |
No TCP frame for 15 seconds |
UDP heartbeat timeout |
No accepted UDP datagram for 120 seconds |
Application messages (type ≥ 100) before authentication are discarded with a warning. This server does not send MsgError for them [ReflectorClient.cpp lines 516–526]. The stock client disconnects if it receives type ≥ 100 before MsgAuthOk [ReflectorV2Logic.cpp lines 852–857]. The stock client also refuses to send type ≥ 100 before that point [ReflectorV2Logic.cpp lines 1304–1310].
There is no application-level close message. Either side closes the TCP connection. This server then broadcasts MsgNodeLeft if the client had a callsign [Reflector.cpp lines 865–869].
B.8.6 Messages a minimal client may ignore
A client that only talks on one preselected talk group still needs types 1, 5, 6, 10, 11, 12, 13, 100, and 111 in the version 2 layout, plus UDP types 1, 101, 102, and 103. It SHOULD send 106. It MAY ignore, after a successful unpack, types 101, 102, 103, 104, 105, 109, and 110. It MAY omit 107, 109, 110, 112, and 113. If it sends 112 or 113, the bodies are Part A.5. This server will not send types 14–21 or 114 to a version 2.0 client.
B.9 State diagram
stateDiagram-v2
[*] --> ExpectChallenge: TCP connected, send version 2.0
ExpectChallenge --> ExpectAuthOk: MsgAuthChallenge, send HMAC response
ExpectAuthOk --> ExpectServerInfo: MsgAuthOk
ExpectServerInfo --> Connected: MsgServerInfo, send node info, UDP heartbeat
ExpectChallenge --> [*]: MsgError, downgrade, or timeout
ExpectAuthOk --> [*]: unexpected message or timeout
ExpectServerInfo --> [*]: unexpected message or timeout
Connected --> [*]: MsgError, timeout, or TCP close
This server’s states for this session are expect-version, then expect-auth-response, then connected. The certificate states in Part C are not used.
B.10 Timing
Client constants are Part A.12. Additional client constants [ReflectorV2Logic.h lines 207–212; ReflectorV2Logic.cpp lines 142–143]:
| Timer | Value |
|---|---|
| Heartbeat tick | 1000 ms |
| Encoder flush timeout | 3000 ms, one-shot |
| Local gap after the last received audio | diff.tv_sec > 3, then the decoder is flushed |
| Default talk-group select timeout | 30 s |
| Default temporary monitor timeout | 3600 s |
The select and monitor timeouts are local UI behavior of the SvxLink logic. This server does not time out a selected talk group.
Server talker and heartbeat limits are Part A.11 and A.12. The first UDP heartbeat from the client both creates the return path and resets the server’s UDP receive counter. A client that waits longer than 120 seconds after TCP accept before sending any UDP datagram receives "UDP heartbeat timeout". The client’s own UDP receive limit is 60 seconds from TCP connect.
B.11 Audio
The codec name is chosen in B.8.3. The payload of UDP type 101 is a uint16 length and then that many codec bytes.
When the name is OPUS, the encoder in this tree is libopus with these settings [AsyncAudioEncoderOpus.cpp constructor lines 117–138; writeSamples lines 632–647; src/CMakeLists.txt lines 50–53]:
| Parameter | Value |
|---|---|
| Sample rate | INTERNAL_SAMPLE_RATE, CMake default 16000 |
| Channels | 1 |
| Application | OPUS_APPLICATION_AUDIO |
| Frame size | 20 ms, which is 320 samples at 16000 Hz |
| Bitrate | 20000 bit/s |
| VBR | enabled |
| Maximum bandwidth | OPUS_BANDWIDTH_MEDIUMBAND |
| Bandwidth | OPUS_AUTO |
| Signal type | OPUS_SIGNAL_VOICE |
| DTX | disabled |
| LSB depth | 16 when OPUS_MAJOR > 0 |
| API | opus_encode_float on float samples |
The bytes passed to writeEncodedSamples are one raw Opus packet, not an Ogg page. A positive nbytes becomes one UDP datagram. Complexity, in-band FEC, and expected packet-loss percent are not set in the constructor; they stay at the libopus defaults unless the client configuration sets OPUS_ENC_<OPTION>. A peer MUST decode that Opus packet at 16000 Hz, mono. opus_encode of int16 PCM with the same controls produces the same packet format.
SPEEX, GSM, S16, and RAW are alternative names. Each datagram is one output of that encoder, with the same uint16 length prefix. This memo does not restate those encoders’ internal frames. The stock server prefers OPUS when it is compiled in.
There is no packet-loss concealment in the reflector session. A gap advances the sequence and the next packet is decoded when it arrives. The stock client logs the gap and writes the received packet to the decoder [ReflectorV2Logic.cpp lines 1409–1440].
B.12 Security considerations
The shared key is authenticated at login and is not transmitted. The proof is HMAC-SHA1 over the 20-byte nonce, compared with CRYPTO_memcmp [ReflectorMsg.h lines 456–484]. SHA-1 is the algorithm this code names ("sha1"). The challenge is 20 bytes from RAND_bytes.
After login, TCP frames and UDP datagrams are plaintext. They have no MAC. An on-path attacker can read audio and callsigns, and can change, drop, or insert TCP frames and UDP datagrams. The server accepts a version 2 UDP datagram when the client id is known and the source address equals the TCP peer. The client accepts a datagram when the source address and port equal the TCP peer. Those checks bind the datagram to the path’s addresses; they do not authenticate the payload.
The sequence number rejects a datagram that looks more than half the uint16 space behind the expected value. It is not a security association. A later sequence number is accepted and moves the window.
The login HMAC is not repeated on later messages. A captured MsgAuthResponse does not reveal the key, and it does not by itself open a new session, because the server generates a new challenge. Replaying it against a new challenge fails the MAC.
Callsigns and node JSON travel in the clear. MsgError strings in B.8.5 distinguish an illegal callsign from a bad key.
B.13 Differences from version 3.0
Version 2.0 announces major 2. It authenticates with HMAC-SHA1 and AUTH_KEY. Its UDP header is the 6-byte plaintext header in B.7. Node information is type 111 with only a JSON string. It never sends types 14–21 or 114, and this server does not send those to it. Version 3.0 is Part C.
B.14 References
Blob prefix: https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/
| Section | Symbol | Path and lines read |
|---|---|---|
| B.4, B.8.1 | proto_ver(2, 0), onConnected |
src/svxlink/svxlink/ReflectorV2Logic.cpp 127, 785–801 |
| B.7 | ReflectorUdpMsgV2, sendUdpMsg, udpDatagramReceived |
ReflectorMsg.h 210–257; ReflectorV2Logic.cpp 1358–1489; Reflector.cpp 406–440, 1056–1147 |
| B.8.2 | MsgAuthResponse::calcHMAC, handleMsgAuthResponse |
ReflectorMsg.h 412–485; ReflectorClient.cpp 718–781; ReflectorV2Logic.cpp 944–972 |
| B.8.3 | handleMsgServerInfo |
ReflectorV2Logic.cpp 990–1130 |
| B.8.4 | sendEncodedAudio, flushEncodedAudio, handleMsgTalkerStart |
ReflectorV2Logic.cpp 1192–1254, 1331–1355; Reflector.cpp 1159–1250 |
| B.8.5 | sendError, handleHeartbeat |
ReflectorClient.cpp 1210–1298 |
| B.10 | timer constants | ReflectorV2Logic.h 207–212 |
| B.11 | AudioEncoderOpus |
src/async/audio/AsyncAudioEncoderOpus.cpp 117–138, 632–647; src/CMakeLists.txt 50–53 |
Part A references apply to every encoding claim.
B.15 Unresolved
- Inference: Whether the production FM-Funknetz reflector still speaks version 2.0. The network’s pages tell operators to use
TYPE=ReflectorV2. This memo did not open a connection to that reflector. - Packet-loss concealment is outside this protocol. The implementation logs a sequence gap and delivers the packets it has.
- The man page’s one-second default for
SQL_TIMEOUT_BLOCKTIMEdoes not match the binary default of 60 seconds (Part A.11). TG_FOR_V1_CLIENTSis 1 in the binary initializer and 999 in the shipped configuration template.- The stock version 2.0 client does not disconnect when it understands no advertised codec. What a different server does in that case is not defined here.
ProtoVer::operator!=ignores the minor number. The version 2.0 handshake does not call it.
Part C — SvxReflector Protocol Version 3.0
C.1 Status of this memo
Implementation-derived description of the version 3.0 session at commit fcd13014810af75d1852b3f2cfc8d9806178d0ab. Not an IETF standard. Encoding, the catalogue, callsigns, talk groups, and server timers are Part A. HMAC-SHA1 as the primary login of version 2.0 is Part B. This memo specifies the certificate session and the UDP cipher. A reader does not need Part B’s UDP header to implement version 3.0.
C.2 Abstract
A client opens one TCP connection, announces protocol 3.0, optionally fetches a CA bundle, and then both sides speak TLS 1.2 or later on that TCP connection. The client is identified by the common name of its certificate, or, if it has none yet, by a certificate signing request and a possible HMAC fallback. It sends the reflector a random AES-128 key inside TLS. UDP datagrams are AES-128-GCM with an 8-byte tag. Audio forwarding rules are the same as Part A.11.
C.3 Table of contents
- C.4 Introduction
- C.5 Terminology
- C.6 Transport
- C.7 UDP datagram
- C.8 Session
- C.9 State diagram
- C.10 Timing
- C.11 Audio
- C.12 Security considerations
- C.13 Differences from version 2.0
- C.14 References
- C.15 Unresolved
C.4 Introduction
Version 3.0 is the session spoken by logic type Reflector. The MsgProtoVer class defaults are major 3 and minor 0, and this client sends that default [ReflectorLogic.cpp line 159 and lines 984–1003]. A downgrade to version 2 is present only inside #if 0. This client disconnects on MsgProtoVerDowngrade [ReflectorLogic.cpp lines 1226–1255].
This server treats major ≥ 3 as this session [ReflectorClient.cpp lines 649–656].
C.5 Terminology
A client MUST send major 3 and minor 0, or another version that this server maps onto this session (major ≥ 3 and ≤ 3.0), as its first TCP payload.
A client MUST complete TLS after MsgStartEncryption before sending MsgNodeInfo. A client MUST put a 16-byte AES-128 key and a 6-byte IV random in that message. This server stores those fields and uses them as the UDP cipher key and IV prefix.
The HMAC challenge is not the normal login of this session. It remains a server fallback after a signing request, specified in C.8.4.
C.6 Transport
TCP and UDP addresses are Part A.7. The version 3.0 client sets the TCP payload maximum to 32768 at construction and leaves it there [ReflectorLogic.cpp lines 219–220]. This server still starts at a receive maximum of 64. It raises that to 4096 when it sends MsgStartEncryption, and to 32768 when authentication completes [ReflectorClient.cpp lines 199–200, 711, and 1343].
Messages before MsgStartEncryption are plaintext frames from Part A.4. After both sides call enableSsl, further writes are TLS application data. The 4-byte length and the reflector payload sit inside the TLS records. The TLS object is TLS_method() with minimum version TLS1_2_VERSION and SSL_OP_ALL [AsyncSslContext.h lines 138–147]. enableSsl uses SSL_set_accept_state on the server and SSL_set_connect_state on the client, and sets SSL_VERIFY_PEER [AsyncTcpConnection.cpp lines 251–280]. SSL_VERIFY_FAIL_IF_NO_PEER_CERT is not set, so a client certificate is optional at the TLS layer.
The client’s UDP socket is EncryptedUdpSocket with local port 0 [ReflectorLogic.cpp lines 1712–1714].
C.7 UDP datagram
C.7.1 Cipher
The cipher name is the string AES-128-GCM [ReflectorMsg.h UdpCipher::NAME, line 1504]. The key length is the OpenSSL key length of that cipher, which is 16 bytes. The client generates it with randomBytes and setCipherKey() [ReflectorLogic.cpp lines 1715–1722; AsyncEncryptedUdpSocket.cpp lines 218–227].
Constants [ReflectorMsg.h lines 1504–1509]:
| Name | Value |
|---|---|
| Associated data for an ordinary datagram | 4 bytes |
| Tag | 8 bytes |
| IV | 12 bytes |
| IV random prefix | 12 − 4 − 2 = 6 bytes |
The tag is the GCM tag truncated by EVP_CTRL_AEAD_GET_TAG / EVP_CTRL_AEAD_SET_TAG to 8 bytes [AsyncEncryptedUdpSocket.cpp lines 311–315 and 397–412]. Both sides call setTagLength(8) [Reflector.cpp lines 310–311; ReflectorLogic.cpp line 1732].
C.7.2 IV
The 12-byte IV is packed as [ReflectorMsg.h UdpCipher::IV, lines 1528–1581]:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| random prefix (6 bytes) |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| | client id (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| counter (32, big-endian) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The random prefix is the 6 bytes from the client’s MsgNodeInfo. If the vector is shorter, the remaining prefix bytes are 0. Extra bytes are ignored.
Direction changes the client-id field:
- Client to server: the client id from
MsgServerInfo[ReflectorLogic.cppsendUdpMsglines 2252–2253]. - Server to client: the client-id field is 0 [ReflectorClient.cpp
udpCipherIVlines 365–367]. The client’s receive path sets the same field to 0 [ReflectorLogic.cpp lines 2090–2091].
The counter is a uint32.
C.7.3 Associated data and on-wire order
Ordinary associated data is the counter alone, 4 bytes, big-endian [ReflectorMsg.h UdpCipher::AAD, lines 1511–1517].
The client’s first datagram uses InitialAAD, which packs the parent counter (value 0) and then the real client id as a uint16. That associated data is 6 bytes [ReflectorMsg.h lines 1519–1526; ReflectorLogic.cpp sendUdpRegisterMsg lines 2278–2281].
EncryptedUdpSocket::write lays the datagram out as associated data, then the 8-byte tag, then the ciphertext [AsyncEncryptedUdpSocket.cpp lines 278–329]:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| associated data (4 bytes, or 6 on registration) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| GCM tag (8 bytes) |
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +
| ciphertext of (uint16 type || message body) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The plaintext inside the cipher is a version 3 UDP message: uint16 type, then the A.6 body. There is no client id and no sequence in that plaintext. The counter in the IV and in the associated data is the sequence.
GCM additional data is the associated-data bytes. They are copied in the clear and also passed to EVP_EncryptUpdate as additional data. The plaintext is encrypted with EVP_EncryptUpdate / EVP_EncryptFinal_ex. The receiver checks the tag with EVP_DecryptFinal_ex. A failed tag is not delivered upward [AsyncEncryptedUdpSocket.cpp lines 370–433].
C.7.4 Sequence
The client’s registration uses counter 0 and does not consume the send counter. That send counter is set to 1 before the socket is created, and later datagrams post-increment it [ReflectorLogic.cpp lines 1713 and 2268–2274].
This server’s send counter starts at 0 and is post-incremented, so the first server datagram uses counter 0 [ReflectorClient.cpp line 197; ReflectorClient.h line 481].
On receive, a counter less than the expected counter is dropped. A counter greater than expected is accepted and the expected value becomes received + 1. The comparison is unsigned and has no 32767-wide window [ReflectorLogic.cpp lines 2144–2160; Reflector.cpp lines 1109–1127]. The client’s expected counter starts at 0, which matches the server’s first datagram.
This server peeks the first 4 bytes. If that counter is 0, it treats the datagram as registration: the associated-data length is 6, the IV’s client-id field is the real client id, and the client id is taken from those extra two bytes [Reflector.cpp udpCipherDataReceived lines 899–942]. Any other counter uses associated-data length 4 and looks up the client by source address and port. A counter of 0 always executes setUdpRxSeq(0) before the rest of the receive path, including when the client was already registered [Reflector.cpp lines 1014–1044].
The client’s receive path always uses a 4-byte associated data and an IV client-id of 0, because this server does not send InitialAAD.
While the client is still waiting for the first server UDP heartbeat, expiry of the UDP transmit interval sends the registration datagram again (sendUdpRegisterMsg), which repeats counter 0 under the same key and the same IV [ReflectorLogic.cpp lines 2350–2355].
C.8 Session
C.8.1 Plaintext preface
- The client connects TCP and sends
MsgProtoVer3.0. It waits forMsgCAInfo[ReflectorLogic.cpp lines 984–1003]. - This server sends
MsgCAInfo: auint16equal to the CA bundle PEM size, and avector<uint8_t>that is SHA-256 of those PEM bytes (MsgCABundle::MD_ALGis"sha256") [Reflector.cpp lines 1710–1720; ReflectorMsg.h line 680]. - The client requests
MsgCABundleonly whenCERT_DOWNLOAD_CA_BUNDLEis true (the default) and the local CA file is missing or unreadable. If the file exists and its SHA-256 differs, the client logs a warning, does not request a replacement, and continues with the file it has [ReflectorLogic.cpp lines 1328–1392]. The source comment says the new bundle is not verified against the old one, so an existing file is kept. - On a bundle request, this server sends
MsgCABundle: CA PEM, a signature, and the issuing certificate PEM. The signature is SHA-256 over the CA PEM bytes, produced with the issuing CA private key [Reflector.cpp lines 1719–1720]. The client verifies that signature with the public key in the supplied certificate PEM [ReflectorLogic.cpp lines 1433–1449]. The following source comment says the signing certificate is not checked against the previous CA. On success the client writes the PEM and continues. On failure it disconnects. - The client sends
MsgStartEncryptionRequest. - This server, in the expect-start-encryption state, raises the receive maximum to 4096, sends
MsgStartEncryption, and callsenableSsl(true)[ReflectorClient.cpp lines 684–714]. - The client, on
MsgStartEncryption, callsenableSsl(true)and waits for the TLS handshake [ReflectorLogic.cpp lines 1485–1501].
MsgCABundleRequest or MsgStartEncryptionRequest in any other server state closes the TCP connection without MsgError.
C.8.2 TLS identity
The client generates a 2048-bit RSA key if CERT_KEYFILE does not exist [ReflectorLogic.cpp lines 323–329]. Its CSR is version 1, subject CN equal to CALLSIGN, basic constraints critical, CA:FALSE, key usage critical, digitalSignature, keyEncipherment, keyAgreement, and extended key usage clientAuth. Optional CERT_EMAIL values are subject-alternative names of type email. The CSR is signed by the client’s own key [ReflectorLogic.cpp lines 346–397].
The client checks the server certificate after the handshake:
- OpenSSL verification must return
X509_V_OK. - The peer certificate must match the remote hostname (
matchHost, with one trailing dot removed) or the remote IP (matchIp).
Otherwise the client disconnects [ReflectorLogic.cpp lines 1058–1103].
This server, when the handshake completes:
- If the peer certificate is null, it sends
MsgClientCsrRequestand waits forMsgClientCsr[ReflectorClient.cpp lines 425–432]. - If a certificate is present, the common name must pass
callsignOk. Failure closes the connection withoutMsgError. Success authenticates that common name as the callsign and continues at C.8.5 [ReflectorClient.cpp lines 447–477].
C.8.3 Certificate provisioning
MsgClientCsr carries the PEM. This server accepts it in the expect-CSR state or after the client is already connected. It rejects a PEM that is not a CSR with "Invalid CSR received". checkCsr returns one of these strings, which are sent as MsgError [Reflector.cpp lines 741–754]:
Certificate signing request with invalid callsign '<CN>'Certificate signing request with no or invalid CERT_EMAIL
If the server has a certificate whose public key matches the pending CSR, it sends MsgClientCert (PEM) and waits to disconnect. The stock client writes that certificate and reconnects [ReflectorLogic.cpp handleMsgClientCert around lines 1532–1617; ReflectorClient.cpp lines 833–844].
If this server is still in expect-CSR and has no matching certificate, it logs that fact and falls through to sendAuthChallenge [ReflectorClient.cpp lines 845–850].
C.8.4 HMAC fallback
The challenge and HMAC-SHA1 construction are Part B.8.2. This server sends that challenge only on the path in C.8.3, after TLS is up. The version 3.0 client handles MsgAuthChallenge only in the expect-auth-answer state, which is where it sits after the handshake and after a CSR request (the CSR handler does not change state). It reads AUTH_KEY if present and sends MsgAuthResponse [ReflectorLogic.cpp lines 1259–1291]. AUTH_KEY is not required for the client to start. An empty key is what gets signed if the key is unset.
MsgAuthOk in the expect-auth-answer state moves the client to expect-server-info. If the local CSR file is newer than the certificate file, the client sends MsgClientCsr again [ReflectorLogic.cpp lines 1295–1324].
C.8.5 Server info and UDP key
MsgAuthOk and MsgServerInfo follow a successful certificate common name or a successful HMAC, as in Part B.8.2, including MsgNodeJoined and the version < 0.7 node list. A version 3.0 client is not forced onto TG_FOR_V1_CLIENTS.
On MsgServerInfo the client selects a codec. If no name works, it disconnects. That is different from version 2.0 [ReflectorLogic.cpp lines 1662–1684].
It then creates the encrypted UDP socket, generates the 6-byte IV prefix and the 16-byte key, and sends MsgNodeInfo (type 111):
vector<uint8_t>IV random (6 bytes)vector<uint8_t>AES key (16 bytes)stringJSON
The JSON is single-line and contains sw, swVer, projVer, and, when uname succeeds, machineArch. NODE_INFO_FILE is merged first, then those keys are overwritten [ReflectorLogic.cpp lines 640–648 and 1740–1749].
This server, for major ≥ 3, stores the IV random and the key and then sends MsgStartUdpEncryption before it parses the JSON. A JSON parse error is a warning. An unpack failure of type 111 is "Illegal MsgNodeInfo protocol message received" [ReflectorClient.cpp lines 917–937].
The client, on MsgStartUdpEncryption, sends the registration datagram: InitialAAD with its client id, plaintext UDP type 1 (heartbeat), encrypted as in C.7 [ReflectorLogic.cpp lines 1983–2004].
This server’s first accepted registration datagram records the UDP source and sends a UDP heartbeat back (counter 0, IV client-id field 0). The client accepts that datagram only from the TCP peer’s address and port. When the plaintext type is heartbeat and the client is waiting for one, it marks the connection established, sends MsgSelectTG if its talk group is non-zero, and sends MsgTgMonitor if it has monitor talk groups [ReflectorLogic.cpp lines 2164–2183]. Audio sent before that heartbeat is dropped, because isLoggedIn is still false [ReflectorLogic.cpp lines 2186–2189].
C.8.6 Audio and the rest of the catalogue
After the state is connected, MsgUdpAudio, MsgUdpFlushSamples, and MsgUdpAllSamplesFlushed have the same talk-group, talker, and flush behavior as Part A.11 and Part B.8.4. The bytes are the plaintext of C.7, not the version 2 header.
Talker TCP messages for this client use the major ≥ 2 layout (talk group plus callsign). MsgRequestQsy is sent to this client when it is on the requester’s talk group.
TCP types 102, 103, 104, 105, and 109 are informational in the same way as in Part B. Signal strength and transmitter status are TCP 112 and 113. The stock client sends them from local receiver and transmitter events; a headset client MAY omit them.
C.8.7 Errors and disconnect
MsgError handling and the server strings in Part B.8.5 apply, plus the CSR strings in C.8.3. Additional version 3.0 client disconnects, without a reflector MsgError, are: unexpected CA, bundle, or start-encryption messages; a bad CA signature; TLS verification failure; hostname mismatch; an unexpected MsgStartUdpEncryption; failure to construct the AES-GCM socket.
Unexpected MsgCABundleRequest and MsgStartEncryptionRequest make this server call disconnect() with no MsgError [ReflectorClient.cpp lines 668–674 and 689–695].
Application types before authentication are discarded by this server with a warning, and they make this client disconnect [ReflectorLogic.cpp lines 1132–1138].
C.9 State diagram
stateDiagram-v2
[*] --> ExpectCA: TCP connected, send version 3.0
ExpectCA --> ExpectBundle: local CA missing, send bundle request
ExpectBundle --> ExpectStartEnc: bundle stored, send start-encryption request
ExpectCA --> ExpectStartEnc: local CA usable, send start-encryption request
ExpectStartEnc --> ExpectTls: MsgStartEncryption, start TLS
ExpectTls --> ExpectAuthAnswer: TLS ready, peer verified
ExpectAuthAnswer --> ExpectServerInfo: certificate CN accepted, or HMAC MsgAuthOk
ExpectAuthAnswer --> ExpectAuthAnswer: CSR requested or certificate PEM received
ExpectServerInfo --> ExpectUdpEnc: MsgServerInfo, send type 111 with key
ExpectUdpEnc --> ExpectUdpHeartbeat: MsgStartUdpEncryption, send registration
ExpectUdpHeartbeat --> Connected: UDP heartbeat from the TCP peer
Connected --> [*]: MsgError, timeout, or TCP close
This server’s states for this session are: expect-version, expect-start-encryption, expect-TLS-ready, expect-CSR or expect-auth-response, then connected. UDP encryption is not a separate TCP state on the server; it starts when type 111 has been stored and type 114 has been sent.
C.10 Timing
The 1000 ms tick and the client counters in Part A.12 apply. The version 3.0 UDP transmit counter, while the client is still in expect-UDP-heartbeat, retransmits the counter-0 registration instead of sending a new counter [ReflectorLogic.cpp lines 2350–2355].
The flush timer is 3000 ms. The local audio gap rule is diff.tv_sec > 3 [ReflectorLogic.cpp lines 2341–2346]. Server limits are Part A.11 and A.12.
The client’s 60-second UDP receive counter starts at TCP connect, so the TLS handshake and the UDP registration have to finish inside that window or the client disconnects. The server’s 120-second UDP receive counter starts at TCP accept.
Certificate renewal is a server timer. It is at least 10 minutes after connect, and otherwise one minute after the certificate’s renewal time. The server then sends MsgClientCert and expects the client to disconnect [ReflectorClient.cpp lines 462–474 and 1416–1427]. The fraction RENEW_AFTER is used to compute that time from the certificate’s validity; the wire effect is a later MsgClientCert, not a new message type.
C.11 Audio
Codec negotiation matches Part A.10, except a version 3.0 client that finds no usable name disconnects before creating the UDP socket [ReflectorLogic.cpp lines 1676–1684].
The Opus parameters are the same encoder as Part B.11: mono, 16000 Hz by default, 20 ms, 20000 bit/s, VBR, voice, maximum bandwidth medium-band, opus_encode_float, one raw Opus packet per UDP plaintext. The packet is then encrypted as C.7. Loss behavior is the counter rule in C.7.4. No concealment step is specified.
C.12 Security considerations
Before MsgStartEncryption, the version number, CA size, CA SHA-256, and optional CA bundle are plaintext. The bundle signature is SHA-256 over the PEM, verified with the public key in the certificate the server sends in the same message. This client does not chain that certificate to a previously trusted CA when the local file is absent. When a local file already exists and the digest differs, this client keeps the old file.
After enableSsl, TCP frames are TLS with a minimum of TLS 1.2. The server authenticates with a certificate that this client checks against the hostname or IP. The client authenticates with a certificate common name, or with the HMAC fallback inside the TLS session, or by sending a CSR and waiting for an operator to sign it. The CSR itself is not proof of a callsign beyond the later checks.
The UDP key and IV prefix travel in MsgNodeInfo after TLS is up. UDP uses AES-128-GCM. The associated data and the 8-byte tag authenticate the counter and the ciphertext. An on-path attacker who does not have the key cannot read the audio of a datagram that verifies, and a modified datagram fails EVP_DecryptFinal_ex and is dropped.
The GCM tag is 8 bytes, not 16. That is the length passed to EVP_CTRL_AEAD_SET_TAG.
Client-to-server and server-to-client IVs differ in the client-id field (the real id versus 0), so the same counter in the two directions is not the same IV. The registration datagram and every retransmission of it use counter 0, the same random prefix, and the same key, so those transmissions repeat an IV. Ordinary audio counters increase by one per datagram until the uint32 wraps. A later counter of 0 is also the registration peek on this server.
The server accepts UDP only after it can decrypt with the key from type 111 and, after registration, only from the recorded source address and port. The client accepts UDP only from the TCP peer’s address and port, decrypted with the same key.
Login HMAC, when the fallback runs, has the properties in Part B.12 and is carried inside TLS.
C.13 Differences from version 2.0
Version 3.0 announces major 3. The plaintext phase ends at MsgStartEncryption. Identity is a certificate common name, with HMAC-SHA1 only as the fallback in C.8.4. UDP is AES-128-GCM rather than the 6-byte header in Part B.7. Type 111 carries the key and IV prefix plus JSON, and the JSON adds machineArch when uname works. The client waits for a decrypted UDP heartbeat before it selects a talk group or emits audio. A codec it cannot use ends the session.
C.14 References
Blob prefix: https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/
| Section | Symbol | Path and lines read |
|---|---|---|
| C.4 | MsgProtoVer default, onConnected |
ReflectorMsg.h 311–316; ReflectorLogic.cpp 159, 984–1003, 1226–1255 |
| C.6 | SslContext, enableSsl |
src/async/core/AsyncSslContext.h 133–147; AsyncTcpConnection.cpp 251–280 |
| C.7 | UdpCipher, EncryptedUdpSocket::write |
ReflectorMsg.h 1499–1582; AsyncEncryptedUdpSocket.cpp 244–329, 370–433 |
| C.7.4 | udpCipherDataReceived, sendUdpRegisterMsg |
Reflector.cpp 899–970, 996–1127; ReflectorLogic.cpp 2071–2092, 2278–2281, 2350–2355 |
| C.8.1 | handleMsgCAInfo, handleMsgCABundle, handleMsgStartEncryption |
ReflectorLogic.cpp 1328–1501; ReflectorClient.cpp 649–715 |
| C.8.1 | CA digest and signature | Reflector.cpp 1710–1720 |
| C.8.2 | CSR fields, onSslConnectionReady |
ReflectorLogic.cpp 323–397, 1058–1103; ReflectorClient.cpp 410–477 |
| C.8.5 | handleMsgServerInfo, handleNodeInfo |
ReflectorLogic.cpp 1621–1749, 1983–2004; ReflectorClient.cpp 917–937 |
| C.11 | AudioEncoderOpus |
AsyncAudioEncoderOpus.cpp 117–138, 632–647 |
C.15 Unresolved
- Inference: Whether any given production network, including FM-Funknetz, has switched its reflector to version 3.0. This memo did not connect to one.
- The source records a FIXME: a received CA bundle’s signing certificate is not verified against the CA the client already holds, and a digest mismatch on an existing file does not download a replacement [ReflectorLogic.cpp lines 1369–1373 and 1452–1453].
- Registration retransmission repeats the AES-GCM IV (C.7.4). How long a deployment can run before a
uint32audio counter wraps is a function of the packet rate; the protocol text does not define wrap behavior beyond the comparisons in the source. - Packet-loss concealment is not specified. A gap updates the expected counter and the next decrypted packet is delivered to the decoder.
- The man-page and binary defaults for
SQL_TIMEOUT_BLOCKTIMEdiffer (Part A.11).
jayReflector
dj1jay/jayReflector at 50997ed21a37c7c05c132eca5ebb07bab4b80fc7 uses the same client-facing TCP type numbers 1, 5, 6, 10–21, and 100–114, and the same UDP type numbers 1 and 101–104, with the same version 2 header versus version 3 cipher split. ReflectorClient.cpp in that tree switches on the same Msg* cases as upstream. Types 115–122 (MsgTrunkHello, MsgTrunkTalkerStart, MsgTrunkTalkerStop, MsgTrunkAudio, MsgTrunkFlush, MsgTrunkHeartbeat, MsgTrunkNodeList, MsgTrunkFilter) are sent on the trunk and satellite TCP sessions (TrunkLink.cpp, SatelliteLink.cpp), not on the node client connection. They are not part of Protocol Version 2.0 or 3.0 as a station speaks them. MQTT, talk-group mapping, and satellite policy are outside this file.