Source: Reverse-engineered from
cloud-drive-daemon(ARM64, macOS, version 4.0 build 17889) Binary path:~/Library/Application Support/SynologyDrive/SynologyDrive.app/Contents/MacOS/cloud-drive-daemonSource files referenced in debug strings://Users/fct/synosrc/dog-builder-4.0/source/synosyncfolder/lib/protocol/proto-common.cpp
The protocol operates over TCP port 6690 with optional TLS upgrade. The connection sequence is:
- Client opens a raw TCP connection to
<server>:6690 - Client optionally negotiates TLS (see §3)
- Client authenticates (see §4)
- Client enters the request/response loop
The binary implements two distinct serialization formats that share the same TCP connection:
| Layer | Name | Magic | Used By | Serialization |
|---|---|---|---|---|
| Legacy | Section-based (SCMD) | 0x25521814 |
Auth, events, keepalive, SSL upgrade, legacy sync | Tag-Value sections with SPROTO_ID tags |
| Modern | PObject-based (CloudStation) | 0x25521814 |
File operations (download, upload, share, settings) | Recursive map/array/string/int serialization via PStream |
Both layers share the same 8-byte wire header (magic 0x25521814) but differ in payload encoding. The legacy layer uses flat Tag-Value sections, while the modern layer uses PStream-serialized PObject trees. The PROTO_VERSION byte is 0x46 (70 decimal) for the current protocol version.
Every message starts with an 8-byte header:
Offset Size Type Field Description
0 4 uint32 magic Always 0x25521814 (big-endian: bytes 25 52 18 14)
4 1 uint8 version Protocol version (0x46 = 70 decimal)
5 1 uint8 command SCMD_xxx command type (see §2.2)
6 2 uint16 pkt_len Payload length, big-endian (often 0; actual length determined by payload)
Reading (ProtoReadHeader):
int ProtoReadHeader(Channel& chan, uint16_t& pkt_len, uint8_t& version, uint8_t& cmd) {
chan.SetTimeout(10);
int32_t magic;
chan.ReadInt32(&magic);
chan.ReadUInt8(&version);
chan.ReadUInt8(&cmd);
chan.ReadUInt16(&pkt_len);
if (magic != 0x25521814) return -5; // invalid protocol
return 0;
}Writing (ProtoWriteHeader):
int ProtoWriteHeader(Channel& chan, uint16_t pkt_len, uint8_t cmd) {
chan.WriteInt32(0x25521814); // magic
chan.WriteUInt8(PROTO_VERSION); // version
chan.WriteUInt8(cmd); // command
chan.WriteUInt16(pkt_len); // payload length
return 0;
}| ID | Name | Direction | Purpose |
|---|---|---|---|
| 0x00 | (Unknown) | — | Invalid/default |
| 0x01 | SCMD_REQUEST | C→S | Generic request wrapper |
| 0x02 | SCMD_KEEP_ALIVE | C↔S | Connection keepalive |
| 0x03 | SCMD_AUTH | C→S | Authentication (v1, legacy) |
| 0x04 | SCMD_NOTIFY | S→C | Server push notification |
| 0x05 | SCMD_LOGOUT | C→S | Disconnect/logout |
| 0x06 | SCMD_EVT_PULL | C→S | Pull sync events from server |
| 0x07 | SCMD_EVT_RECV | S→C | Receive sync events (v1) |
| 0x08 | SCMD_NEW_EVT_INFO | S→C | New event notification |
| 0x09 | SCMD_EVT_SEND | C→S | Send sync events to server |
| 0x0A | SCMD_USER_CTRL | C→S | User control command |
| 0x0B | SCMD_TEST_CONN | C→S | Connection test |
| 0x0C | SCMD_RESPONSE | S→C | Generic response |
| 0x0D | SCMD_USER_CHECK | C→S | Verify user credentials |
| 0x0E | SCMD_REQUEST_CONNECT | C→S | Request new connection |
| 0x0F | SCMD_EVT_REMOVE | C→S | Remove/acknowledge events |
| 0x10 | SCMD_GET_FILTER | C→S | Get file filter rules |
| 0x11 | SCMD_LIST_VIEW | C→S | List shared folders/views |
| 0x12 | SCMD_AUTH_2 | C→S | Authentication (v2, current) |
| 0x13 | SCMD_EVT_RECV_2 | S→C | Receive sync events (v2) |
| 0x14 | SCMD_REQUEST_SSL | C→S | Request SSL upgrade (v1) |
| 0x15 | SCMD_EVT_RECV_3 | S→C | Receive sync events (v3) |
| 0x16 | SCMD_REQUEST_SSL_2 | C→S | Request SSL upgrade (v2, current) |
| 0x17 | SCMD_PKG_VER_QUERY | C→S | Query server package version |
| 0x18 | SCMD_GET_FOLDER_LIST | C→S | Get folder listing / file download |
| 0x19 | (unnamed) | C→S | Server info query |
| 0x1a | (unnamed) | C↔S | Multi-result node listing |
| 0x1c | (unnamed) | C↔S | Version listing |
| 0x1d | (unnamed) | C→S | Share link operations |
After the header, the legacy protocol body consists of TLV sections. Each section is identified by a 1-byte SPROTO_ID tag. The tag determines the wire type:
Fixed-size fields: [tag_byte][value_bytes] (1, 2, 4, or 8 bytes, little-endian)
Variable-length fields: [tag_byte][length_uint16][data_bytes]
// Writing a fixed-size section
int ProtoWriteSection(Channel& chan, uint8_t section_id, uint8_t value) {
SecAttr* attr = ProtoGetSecAttr(section_id);
if (!attr || attr->type != 1) return -5; // PROTO_ERR_INVALID
chan.WriteUInt8(section_id); // tag
chan.WriteUInt8(value); // value
return 0;
}
// Writing a variable-length section
int ProtoWriteSection(Channel& chan, uint8_t section_id, const string& value) {
SecAttr* attr = ProtoGetSecAttr(section_id);
if (!attr || attr->type != 3) return -5;
chan.WriteUInt8(section_id); // tag
chan.WriteUInt16(value.length()); // length prefix
chan.Write(value.data(), value.length()); // data
return 0;
}Complete field table:
| ID | Name | Wire Type | Size | Description |
|---|---|---|---|---|
| 0x01 | RES | uint8 | 1 | Result/response code |
| 0x02 | TYPE | uint8 | 1 | Message/operation type |
| 0x03 | USER | vardata | var | Username string |
| 0x04 | PASSWD | vardata | var | Password string |
| 0x05 | CLIENT | vardata | var | Client identifier string |
| 0x06 | SESSION | vardata | var | Session token string |
| 0x07 | PATH | vardata | var | File path string |
| 0x08 | NOTIFY | uint8 | 1 | Notification flag |
| 0x09 | SID | uint64 | 8 | Session ID |
| 0x0A | PREFIX | vardata | var | Path prefix string |
| 0x0B | EVT_COUNT | uint64 | 8 | Event count |
| 0x0C | SEQID | uint32 | 4 | Sequence ID |
| 0x0D | EVTID | uint64 | 8 | Event ID |
| 0x0E | FILETYPE | uint8 | 1 | File type (file/dir/symlink) |
| 0x0F | FILESTATUS | uint8 | 1 | File sync status |
| 0x10 | FILEHASH | vardata | var | File content hash |
| 0x11 | FILETIME | uint32 | 4 | File modification time (unix timestamp) |
| 0x12 | FILESIZE | uint64 | 8 | File size in bytes |
| 0x13 | FILEUPDATER | vardata | var | Last updater username |
| 0x14 | FILEOP | uint8 | 1 | File operation type |
| 0x15 | DELTALEN | uint64 | 8 | Delta patch length |
| 0x16 | DELTADATA | vardata | var | Delta patch data |
| 0x17 | DELTACOUNT | uint32 | 4 | Number of delta blocks |
| 0x18 | DELTAID | uint32 | 4 | Delta block identifier |
| 0x19 | DELTATYPE | uint8 | 1 | Delta type (whole file vs patch) |
| 0x1A | USRENABLE | uint8 | 1 | User enable flag |
| 0x1B | SERIAL_NUMBER | vardata | var | Device serial number |
| 0x1C | PROTO_VERSION | uint8 | 1 | Protocol version |
| 0x1D | SERVER_ID | vardata | var | Server identifier (DS ID) |
| 0x1E | ALIVE | uint32 | 4 | Keep-alive interval (seconds) |
| 0x1F | DATA_BLOCK | uint32 | 4 | Data block size |
| 0x20 | SYNC_BLOCK | uint32 | 4 | Sync block size |
| 0x21 | MACHASH | vardata | var | Mac extended attribute hash |
| 0x22 | MACSIZE | uint64 | 8 | Mac extended attribute size |
| 0x23 | AUTH_TYPE | uint8 | 1 | Authentication type |
| 0x24 | VIEW_ID | uint64 | 8 | View/shared folder ID |
| 0x25 | VIEW_NAME | vardata | var | View/shared folder name |
| 0x26 | VIEW_COUNT | uint32 | 4 | Number of views |
| 0x27 | META_BLOCK | uint32 | 4 | Metadata block size |
| 0x28 | FILTER_VERSION | vardata | var | Filter rules version string |
| 0x29 | PROTO_VERSION_2 | vardata | var | Extended protocol version string |
| 0x2A | KEEP_ALIVE_OPTION | uint32 | 4 | Keep-alive configuration |
| 0x2B | KEEP_ALIVE_CONTROL | uint32 | 4 | Keep-alive control flags |
| 0x2C | ACK_OPTION | uint32 | 4 | Acknowledgement options |
| 0x2D | SSL_OPTION | uint32 | 4 | SSL negotiation options |
| 0x2E | SSL_RESPONSE_OPTION | uint32 | 4 | SSL response options |
| 0x2F | PKG_MAJOR_VERSION | uint16 | 2 | Server package major version |
| 0x30 | PKG_MINOR_VERSION | uint16 | 2 | Server package minor version |
| 0x31 | PKG_BUILDNUM | uint32 | 4 | Server package build number |
| 0x32 | FOLDER_COUNT | uint32 | 4 | Number of folders |
| 0x33 | JOB_TOKEN | vardata | var | Resumable job token |
| 0x34 | FILE_OFFSET | uint64 | 8 | File offset for resume |
| 0x35 | PATCH_TOKEN | vardata | var | Delta patch token |
| 0x36 | RESTORE_ID | vardata | var | Restore/device identifier |
PObject is a 24-byte tagged union supporting 7 types:
struct PObject {
int32_t type_tag; // +0x00: discriminant (0-6)
uint64_t value; // +0x08: inline value or pointer to heap data
uint64_t extra; // +0x10: used by some container types
};
// Total: 24 bytes| type_tag | Type | C++ Storage |
|---|---|---|
| 0 | Null | (empty) |
| 1 | Array | std::vector<PObject> |
| 2 | Map | std::map<std::string, PObject> (red-black tree, sorted keys) |
| 3 | Integer | uint64_t inline |
| 4 | String | PObject::SimpleString (custom string class) |
| 5 | Binary | PObject::binary_type {offset, length, path} |
| 6 | BinaryEx | PObject::binary_ex_type (binary + hash) |
All field access in the modern protocol uses string keys via PObject::operator[]("key"). There are no numeric field IDs in the modern layer — the "SPROTO_ID" concept only applies to the legacy section-based layer.
PStream serializes PObjects using a tag-length-value binary encoding. All multi-byte integers are big-endian (network byte order).
| Tag Byte | Type | Wire Encoding |
|---|---|---|
0x00 |
Null | [0x00][0x00] (tag + zero-length indicator) |
0x01 |
Integer | [0x01][size_byte][value_bytes] — variable-length big-endian |
0x10 |
String | [0x10][uint16_be length][utf8_bytes] |
0x30 |
Binary | [0x30][uint64_be length][raw_file_bytes] — streamed via Channel |
0x40 |
End Marker | Terminates Map or Array |
0x41 |
Array Start | [0x41][elements...][0x40] |
0x42 |
Map Start | [0x42][key-value pairs...][0x40] |
0x43 |
BinaryEx | [0x43][map with "binary" + "send_hash" keys][0x40] |
Integers use the minimum number of bytes needed:
Value range Size byte Wire bytes
< 0x100 0x01 [0x01][0x01][1 byte BE]
< 0x10000 0x02 [0x01][0x02][2 bytes BE]
< 0x100000000 0x04 [0x01][0x04][4 bytes BE]
>= 0x100000000 0x08 [0x01][0x08][8 bytes BE]
[0x10][uint16_be length][utf8_bytes]
Note: String keys starting with _ (underscore) have the leading underscore stripped during serialization — the length is decremented by 1 and the pointer advanced by 1. This is an internal convention for field name prefixing.
[0x42] // map start tag
[0x10][key1_len][key1_str] // string key
[type_tag][value1...] // value (any type)
[0x10][key2_len][key2_str] // next key
[type_tag][value2...] // next value
...
[0x40] // end marker
[0x41] // array start tag
[type_tag][element1...] // first element
[type_tag][element2...] // second element
...
[0x40] // end marker
Binary data (file content) is transferred as a single contiguous stream:
[0x30] // binary tag
[uint64_be total_length] // file size
[raw_bytes...] // file data streamed via Channel
Sending (PStream::Send(binary_type)):
- Opens the file at
binary_type.path - Sends tag
0x30 - Sends file length as uint64 big-endian
- Calls
Channel::SendFileData(fd, offset, length, progress_reporter)— bulk transfer via the channel
Receiving (PStream::Recv(binary_type)):
- Reads uint64 big-endian length
- Creates destination file at the configured receive location
- Calls
Channel::RecvFileData(fd, total_size, already_received, progress_reporter)— bulk receive - Truncates file to exact received size
- File data is NOT chunked at the PStream layer — the Channel handles buffering
Wraps binary in a map with integrity hash:
[0x43] // binary_ex start
[0x10][0x06]["binary"] // key: "binary"
[0x30][length][data...] // value: binary_type (file data)
[0x10][0x09]["send_hash"] // key: "send_hash"
[0x10][hash_len][hash_bytes] // value: computed hash string
[0x40] // end marker
Maximum nesting depth: 256 levels (enforced in PStream::RecvDispatch).
The modern protocol also uses the same 8-byte wire header, but with different semantics:
- Magic:
0x25521814(same) - Command: Resolved from
_actionstring via an internal action-to-SCMD map - pkt_len: Always set to 0 (length is implicit in PObject serialization)
The CloudStation layer sends a header via CloudStation::SendHeader(channel, 0x46, protocol_type) where protocol_type determines the server-side handler:
| SCMD Byte | Meaning | Actions |
|---|---|---|
| 0x01 | Single-response RPC | ~90% of actions: upload, get_file_info, search_file, list_team_folder, query_node, delete, copy, move, create, update, labels, sharing, webhooks, etc. |
| 0x11 | User query | query_user |
| 0x12 | Authentication | auth |
| 0x18 | File transfer | download, restore (via ProtoDownload/ProtoRestore) |
| 0x19 | Server info | query_server_info |
| 0x1a | Multi-result listing | list, list_v2, recent, list_shared_with_me, list_shared_with_others, list_ancestor_by_path |
| 0x1c | Version listing | list_version, list_file_version |
| 0x1d | Share link | share_link |
Note: ProtocolClient has a std::map<string, int> designed to override the SCMD byte per action, but it is never populated — the map stays empty and all requests default to 0x01. The real SCMD differentiation is hardcoded at each CloudStation::* call site.
Every modern protocol message carries a @proto metadata envelope:
{
"@proto": {
"type": "header",
"date": 1711234567,
"version": { "major": 7, "minor": 0 },
"body-continue": false,
"x-forward": { // optional, for proxied connections
"<ip>": "192.168.1.1",
"port": 6690,
"proto": "tcp"
}
},
"_action": "download",
"_agent": {
"platform": "mac",
"type": "drive",
"device_uuid": "2e7ef840-...",
"restore_id": "df50e0fe...",
"version": { "major": 4, "minor": 0, "mini": 0, "build": 17889 }
},
"session": "04f7ffbd...",
"view_id": 1,
"root_node_id": 1,
... // action-specific fields
}Large payloads are sent as multi-part messages using the body-continue flag:
- Header frame: Contains
@proto.body-continue = true,_action,_agent, etc. - Body frame(s): Each contains
@proto.body-continue = true(more coming) orfalse(last frame) - Arrays are sent element-by-element, each as a separate body frame
The receiver collects body frames into an array when multiple are present.
Both during send and receive, the protocol transparently handles keep-alive messages:
- Keep-alive messages have
{"type": "keep_alive"}at the top level - They are silently consumed and the receiver loops to get the real response
- The
alivefield in real responses specifies the keep-alive interval in seconds (0 = close connection)
If SSL is required, the client first sends an encrypt_channel request:
→ Header: magic=0x25521814, cmd=SCMD_REQUEST_SSL_2 (0x16)
→ PObject: { "_action": "encrypt_channel", ... }
← PObject: { "error": null } or { "error": { "code": N, "reason": "..." } }
On success, the TCP connection is upgraded to TLS using OpenSSL. The client configures:
ssl_allow_untrust: Whether to accept self-signed certs (from connection settings)- SSL hostname verification
- SSL certificate signature pinning
After optional SSL upgrade:
→ Header: cmd=SCMD_REQUEST_CONNECT (0x0E)
→ PObject: {
"_action": "connect",
"_agent": { "platform": "mac", "type": "drive", "version": {...}, "device_uuid": "..." },
"session": "<session_token>",
"restore_id": "<device_restore_id>",
"client_version": "<version_string>",
"client_type": "drive" // or "drive_backup" for backup tasks
}
← PObject: {
"alive": 300, // keep-alive interval in seconds; 0 = connection refused
"server": {
"package_version": {
"build": 17889
}
}
}
The client validates that the server build number hasn't changed since last connection. If it has, the connection is rejected with error code -33.
Client types: "drive" (normal sync) or "drive_backup" (backup tasks, conn_type == 2).
The client maintains a pool of channels (Connection::pop/Connection::push). Channels have expiration based on the alive interval. Expired channels are transparently reopened via ReopenChannel.
File downloads use the "download" or "batch_download" action:
→ PObject: {
"_action": "download", // or "batch_download"
"session": "...",
"view_id": 1,
"root_node_id": 1,
"task_id": "...", // batch_download only
"dry_run": false, // batch_download: check without downloading
"is_preview": false, // batch_download: preview mode
... auth info ...
}
The sync engine builds download requests with _action = "download" (or "resume_download"):
→ {
"_action": "download",
"session": "...",
"view_id": 1,
"sync_id": 50000,
"max_id": 50100,
"target_sync_id": 50001,
"path": "id:812195821838704701",
"c2_offload": false,
"force_current_version": false
}Critical: the path field is NOT a filesystem path. For servers with build >= 12001 (0x2EE1), the path must be "id:" + file_id (the permanent file identifier string from list/get_file_info responses). For older servers, use the actual filesystem path.
When the sync watch path is not "/" (i.e., syncing a subfolder, not the view root), the code erases view_id from the request and uses the "id:<file_id>" path format. When syncing the root, the full filesystem path from the event context is used instead.
Request fields:
| Field | Type | Description |
|---|---|---|
path |
string | "id:<file_id>" (build >= 12001) or filesystem path (older) |
sync_id |
uint64 | Known sync_id from local DB or event |
max_id |
uint64 | Max known sync ID |
target_sync_id |
uint64 | Specific file version to download |
c2_offload |
bool | Whether to use C2 cloud offload |
force_current_version |
bool | Force download of current version |
Request: [8-byte header] then [PStream-serialized PObject]
The SCMD byte depends on the code path:
ProtocolClient::Request(sync engine path): SCMD 0x01 (the action→SCMD map is empty, always defaults to SCMD_REQUEST)ProtoDownload(standalone low-level path): SCMD 0x18
Response: NO frame header. The response is a raw PStream-serialized PObject. ProtocolClient::RecvResponse goes straight to PStream::Recv.
File content arrives inline within the response PObject as binary_type (tag 0x30) or binary_ex_type (tag 0x43) fields:
- Tag byte
0x30or0x43 uint64_betotal file length- Raw file bytes streamed through the channel
- For
binary_ex_type: after file data, a"send_hash"field for integrity verification
The ProtocolClient sets a receive location (temp directory path) via PStream::SetDefaultRecvLocation so that PStream::Recv knows where to write binary data to disk during deserialization.
PrepareDownloadResponseFromEvent— pre-populates response from cached event dataPrepareDownloadResponseFromLocal— adds locally-available data (for delta downloads)GetUnsatisfiedAttributes— determines what still needs fetching from serverPrepareDownloadRequest— builds the request PObject (see §5.2)SimpleDownload— sends viaProtocolClient::Request- If
sync_idin response matches request → file is already up-to-date, skip ProcessDownloadFile— writes file to disk, applies attributesCommitDatabaseDownload— updates local DB
For files > 512KB (0x80000), resumable download is used: GetResumeDownloadToken → ContinueResumeDownload loop.
The download response PObject contains metadata at the top level and file content nested under "file":
← {
"@proto": { "body-continue": false, ... },
"alive": 300,
"sync_id": 50001,
"max_id": 50100,
"path": "/Documents/report.pdf",
"file_id": "812195821838704701",
"file_type": "file",
"parent_id": "812195821838704640",
"permanent_link": "...",
"exec_bit": { "exec_bit": 0 },
"unix_perm": { "uid": 1000, "gid": 100, "mode": 644 },
"mac_attribute": { "hash": "...", "size": 0 },
"synology_acl": { "acl": "...", "hash": "..." },
"share_priv": { "disabled": 0, "deny_list": "", "ro_list": "", "rw_list": "", "hash": "..." },
"mtime": { "mtime": 1711234567 },
"server": { "package_version": { "build": 17889 } },
"file": {
"data": <binary_type tag 0x30>, // THE ACTUAL FILE CONTENT
"hash": "d41d8cd98f00b204...", // content hash
"size": 375000, // file size
"refer": false, // true = server already has it (dedup)
"refer_local": false, // true = local copy is identical
"is_delta": false // true = data is a delta patch, not whole file
}
}The file content is at response["file"]["data"] as a binary_type (PStream tag 0x30). During PStream deserialization, this binary field is streamed directly to disk at the configured receive location — the PObject stores a path to the temp file, not the bytes in memory.
If the response has no "file" sub-object, the server is returning only metadata (e.g., because the path field in the request didn't correctly identify a file). The path field must be "id:<file_id>" for server build >= 12001.
Dedup cases (no binary data transferred):
file.refer == true: Server already has a copy with matching hashfile.refer_local == true: Local copy matches, no download needed
When file.is_delta == true, the file.data binary contains an rsync-style delta patch (not the whole file). The client:
- Reads
file.sizefor the suggested block size viaFileReader::getSuggestedBlockSize() - Uses
DeltaFileReader::setFile()with the binary data path - Applies the delta against the local base file via
DeltaFileReader::readFile()
For files > 512KB, the client uses resume tokens:
GetResumeDownloadToken— gets a server-issued tokenContinueResumeDownload— sends chunks with the token- On failure, saves state for later continuation
The batch_download action via CloudStation::DownloadFile returns an archive:
← PObject: {
"archive_info": {
"location": "/path/to/archive",
"archive_name": "filename.zip",
"archive_codepage": "utf-8"
}
}
→ PObject: {
"_action": "upload", // or "resume_upload" for resumable
"session": "...",
"view_id": 1,
"sync_id": 12345, // current sync cursor
"max_id": 12345, // max sync ID known
"path": "/relative/path", // parent-relative if parent has view_id
"conflict_policy": "compare_mtime",
"is_dir": false,
"file_type": "regular",
"file": {
"size": 12345, // file size (uint64)
"hash": "md5hex...", // content hash
"data": <binary_type>, // file content (sent as tag 0x30 binary)
"refer": false, // true = server already has content (dedup)
"is_delta": false, // true = sending rsync delta, not full file
"base_size": 0, // base file size for delta
"real_size": 12345, // actual file size (may differ if delta)
"signature": <binary_type> // rsync signature (if signature_offload)
},
"mtime": 1711234567,
"mac_attribute": {
"data": <binary_type> // macOS extended attributes
},
"exec_bit": 0,
"acl_attribute": "...",
"unix_permission": { "uid": N, "gid": N, "mode": N }
}
Important: Before sending, file.data, file.signature, and mac_attribute.data are stripped from the metadata PObject and sent as separate binary frames via ProtocolClient::Request. This means the metadata goes as a PObject map, and binary payloads follow as binary_type (tag 0x30) attachments.
The client computes the file hash before upload. If the hash matches what's in the local database (unchanged file), or if the server's dry-run response returns a file_id, the upload sets file.refer = true and erases file.data — no content transfer needed. The server already has a copy.
For modified files where the server has a previous version:
- Client creates a
synodrive::rsapi::SimpleFileReader - Computes an rsync-style signature with
setSignature()using a block size fromFileReader::getSuggestedBlockSize(fileSize) - If a base version exists in the DB, computes a delta with
setDelta() - Sets
file.is_delta = trueandfile.base_size— only the delta is uploaded - Server can also request signature offload (
signature_offloadflag) or hash offload (hash_offloadflag) in the dry-run response
For large files, the protocol uses server-issued tokens:
- Get token: Action
"resume_get_token"→ server returnsresume_token - Dry-run: Pre-flight check for bandwidth/quota
- Upload chunks: Each chunk sent via
SimpleUploadwith_resume_tokenfield - On pause/abort: Resume state saved to disk (
TempFile::convert_permanent()) - Continue later:
ContinueResumeUploadresends with the saved token
Before large uploads, the client sends an upload request with bandwidth_check = true and optionally reserve_size = <file_size>. The server validates quota/bandwidth constraints without transferring data.
For Synology C2 cloud storage shares:
HandleUploadFileToC2tries uploading to C2 first- On failure,
FallBackToUploadFileToDriveServerfalls back to direct server upload - Delta uploads are disabled for C2 shares
The daemon uses long-polling as its primary change detection mechanism via the "long_poll" action.
Request:
{
"_action": "long_poll",
"timeout": 300,
"profile_digest": "abc123...",
"subscribe": [
{
"sync_id": 12345, // last known sync cursor
"path": "/watched/path",
"view_id": 1,
"node_id": 1,
"lock_id": 0,
"option_change_lock_id": 0,
"recursive": true
}
]
}Response:
{
"changes": [
{ "view_id": 1 }, // which views/shares changed
{ "view_id": 2 }
],
"profile_changed": false, // user profile needs refresh
"profile_digest": "def456...",
"notification": [...] // connection notifications for UI
}Long-poll lifecycle (LongPoller::DoTask):
- Worker 11 sends
long_pollrequest — this blocks until server has changes or timeout - Server responds with a
changesarray listing whichview_ids have new events - For each changed view, client calls
IssueSyncerPullEventto trigger sync workers - If
profile_changedis true, refreshes user info viaQueryUserInfo - On error, falls back to direct
pull_eventfor all subscribed sessions
After long-poll signals changes, the sync workers fetch actual events via "pull_event":
Request:
{
"_action": "pull_event",
"sync_id": 12345, // current cursor position
"path": "/watched/path",
"node_id": 1,
"recursive": true,
"file": { "offset": 0 },
"mtime": { "offset": 0 },
"mac_attribute": { "offset": 0 },
"exec_bit": { "offset": 0 }
}Response:
{
"event_list": [
{
"file_id": "...",
"path": "/changed/file.txt",
"file_op": 1, // operation type
"file_type": 0,
"file_size": 12345,
"file_hash": "...",
"mtime": 1711234567,
"sync_id": 12346
}
],
"next_sync_id": 12350,
"rescan_later": false,
"merge_mode": false,
"server": {
"package_version": { "build": 17889 }
}
}For server version 3+, events reference files by file_id and use convert_req2event_v80. Events are pushed to SyncerEventManager or EventManager::PushServerEvent for processing by sync workers.
Events are not tagged with an operation enum. Instead, the event type is determined by the is_removed field:
is_removed == true→ FileRemoveEvent (file deleted)is_removed == false→ FileAddEvent (file created or modified)
Renames and moves are indicated by a rename sub-object within the event.
Event PObject fields:
| Field | Type | Description |
|---|---|---|
is_removed |
bool | true = delete event, false = add/modify |
path |
string | File path |
file_id |
string | Permanent file identifier |
parent_id |
string | Parent node identifier |
permanent_link |
string | Permanent link |
sync_id |
uint64 | Sync cursor position for this event |
max_id |
uint64 | Max sync ID at time of event |
file.hash |
string | File content hash |
file.size |
uint64 | File size |
mtime.mtime |
uint32 | Modification timestamp |
mac_attribute.hash |
string | macOS extended attributes hash |
mac_attribute.size |
uint64 | macOS extended attributes size |
exec_bit.exec_bit |
uint32 | Unix executable bit |
unix_perm.uid |
uint32 | Unix UID |
unix_perm.gid |
uint32 | Unix GID |
unix_perm.mode |
uint32 | Unix file mode |
synology_acl.acl |
string | Synology ACL string |
synology_acl.hash |
string | ACL hash |
share_priv.disabled |
int32 | Share privilege disabled flag |
share_priv.deny_list |
string | Denied users list |
share_priv.ro_list |
string | Read-only users list |
share_priv.rw_list |
string | Read-write users list |
share_priv.hash |
string | Privilege hash |
rename.opt |
string | Rename operation: "rename" (name change) or "move" (path change) |
The file type is determined by PObjectHelper::GetSupportedFileType(event) which reads file_type string ("file", "dir", "symlink") or falls back to is_dir boolean.
The daemon implements a three-way merge algorithm for conflict detection, comparing base state, local state, and server state:
| Case | Condition | Action |
|---|---|---|
| 1 | No change | Skip |
| 2, 4 | Local modified | Upload |
| 3 | Server modified | Download |
| 5 | Both modified | Conflict resolution |
| 6 | Server removed | Delete local |
| 7 | Server removed + Local modified | Conflict |
| 8 | Local removed | Delete remote |
| 9 | Local removed + Server modified | Conflict |
| 10 | Both removed | Clean up |
| 11, 12 | Both created | Conflict |
| 13 | Local created | Upload |
| 14 | Server created | Download |
| 15 | Server created + both advance | Download |
The daemon runs multiple workers managed by WorkerManager with barrier synchronization:
- Workers 0-5: Sync workers that process upload/download events
- Worker 11: Long-poll worker for server change detection
- Workers can be paused/resumed as a group (barrier-based) for connection changes
- Channel pool capacity: 5 concurrent channels
- On connection change, all workers pause → connection reloads → workers resume
→ {
"_action": "auth",
"client": "SynologyDriveClient",
"client_type": "drive",
"client_version": "4.0.0-17889",
"dry_run": false,
"renew_session": "",
"username": "admin",
"password": "secret",
"otp": ""
}
← {
"session": "04f7ffbd0c793a3a...",
"server_id": "df50e0fe4e6e521c..."
}Auth modes (set by AppendAuthInfo at 0x1004462e0, selected based on available credentials):
A. Session reuse (not first auth):
session: existing session token stringusername: (optional, sent if not anonymous)
B. RSA key-based (normal operation after device pairing):
username: username stringpem.key_fingerprint: RSA public key fingerprintpem.salt: currenttime()as stringpem.signature: RSA signature of base64(username + time_string) signed with private keyotp: (optional, for 2FA)
C. Username/password (first-time login):
username: username stringpassword: password string (cleartext, but over SSL)otp: (optional, for 2FA)
D. Share token: sharing_token field
E. Sudo: sudo field (user string or uid)
Note: There is no legacy section-based auth (SPROTO_ID_USER/SPROTO_ID_PASSWD). The ProtoWriteSection functions exist as dead code but are never called. All auth goes through PObject/PStream.
Session bootstrap flow: The GUI collects credentials → calls CloudStation::AuthSession which opens a dedicated TCP connection with SCMD 0x12 → sends credentials as PObject fields → receives session token and server_id → stores in ConnectionInfo → persisted to sys.sqlite. Subsequent connections use the session token via ProtocolClient::Connect.
→ { "_action": "query_server_info", "get_all": true }
← {
"database_serial": "...",
"database_restore_id": "df50e0fe...",
"server_id": "...",
"package_version": { "major": 4, "minor": 0, "build": 17889 },
"dsm": { "major": 7, "minor": 2, "build": 64570, "fix": 0, "unique": "..." },
"server_alias": "...",
"host_name": "..."
}The database_restore_id is needed as the restore_id for subsequent requests.
Lists all available shares/views:
→ {
"_action": "list_team_folder",
"offset": 0,
"limit": 0,
"sort_by": "name",
"sort_direction": "asc"
}
← {
"view_list": [
{
"view_id": 1,
"file_id": "812195821838704641",
"name": "home",
"capabilities": {
"can_preview": true, "can_read": true, "can_write": true,
"can_delete": true, "can_rename": true, "can_comment": true,
"can_share": true, "can_encrypt": false, "can_organize": true,
"can_download": true, "can_sync": true
},
"enable_versioning": true,
"keep_versions": 32,
"disable_download": false
}
]
}Each view_id identifies a share/folder. The personal "My Drive" corresponds to the "home" view.
Lists directory contents. Three overloads:
By path:
→ {
"_action": "list",
"view_id": 1, // set via SetViewId
"path": "/",
"list_dir_only": false,
"merge_local": true,
"search_criteria": {
"limit": 1000,
"offset": 0,
"sort_by": "name",
"sort_direction": "asc"
}
}merge_local: When true (used by simple UI listings), the server merges locally-pending changes into the response for a unified view. When false (used by sync engine, search/filter queries, and three-way merge), the server returns only canonical server-side state. Rule of thumb: true for browsing, false for sync.
Pagination: The server returns one PObject per request containing node_list (up to limit entries) and total_count. Two pagination modes exist:
- Offset-based (
use_cursor = false): Client sendssearch_criteria.offset, increments by page size each call. Terminates whenoffset + len(node_list) >= total_count. - Cursor-based (
use_cursor = true, default): Client sendssearch_criteria.cursor(server-opaque token from previous response). Version-gated — requires server build >= 10238 (VersionInfo::IsSupportCursor()). Offset and cursor are mutually exclusive — only one is sent per request.
Default page size: 50 (SearchNodeFilter.limit default).
Note: The sync engine does NOT paginate — ThreeWayMergeHelper::GetServerFileList uses list_sync_to_device (see §9.10) which returns all files in a directory in one response. Directory recursion is event-driven (one event per subdirectory, breadth-first). Only the UI/API layer uses offset/cursor pagination.
By node_id:
→ {
"_action": "list",
"view_id": 1,
"node_id": 12345,
"list_dir_only": false,
"merge_local": false
}Response (node_list array of Node objects):
← {
"node_list": [
{
"node_id": 100,
"sync_id": 50000,
"name": "Documents",
"file_size": 0,
"mtime": 1711234567,
"hash": "",
"file_id": "812195821838704700",
"file_type": "dir",
"is_removed": false,
"privilege": "read-write"
},
{
"node_id": 101,
"sync_id": 50001,
"name": "photo.jpg",
"file_size": 2048576,
"mtime": 1711234500,
"hash": "d41d8cd98f00b204...",
"file_id": "812195821838704701",
"file_type": "file",
"is_removed": false
}
],
"total_count": 2
}Node struct fields:
| Field | Type | Description |
|---|---|---|
node_id |
uint64 | Unique node identifier |
sync_id |
uint64 | Sync cursor position |
file_size |
uint64 | File size (0 for dirs) |
mtime |
uint32 | Modification time (unix) |
name |
string | File/folder name |
hash |
string | Content hash |
file_id |
string | Permanent file identifier |
is_removed |
bool | In trash |
file_type |
string | "file", "dir", or "symlink" |
privilege |
string | "read-only", "denied", or full access |
Richer listing with full FileInfo:
→ {
"_action": "list_v2",
"view_id": 1,
"path": "/Documents",
"list_dir_only": false,
"search_criteria": {
"limit": 1000,
"offset": 0,
"sort_by": "name",
"sort_direction": "asc"
},
"extra": ["capabilities", "labels", "node_locking"]
}Response node_list contains full FileInfo objects (70+ fields including capabilities, sharing, labels, locking state, timestamps, etc.).
→ {
"_action": "get_file_info",
"view_id": 1,
"path": "/Documents/report.pdf",
"extra": ["capabilities"]
}
← { "node": { /* full FileInfo object */ } }Optional fields: case_insensitive_path (bool), log_action (string), update_access_time (bool).
Lightweight single-node query:
→ { "_action": "query_node", "view_id": 1, "path": "/Documents" }
← { "node": { "node_id": 100, "sync_id": 50000, "name": "Documents", ... } }→ {
"_action": "search_file",
"search_criteria": {
"keyword": "report",
"file_type": "file",
"limit": 50,
"offset": 0
}
}
← {
"search_list": [ /* FileInfo objects */ ],
"total_count": 15,
"search_time": 42
}Returned by list_v2, get_file_info, search_file, recent:
| Field | Type | Description |
|---|---|---|
file_id |
string | Permanent file identifier |
path |
string | Full path |
display_path |
string | Display path |
name |
string | File name |
parent_id |
uint64 | Parent node ID |
size |
uint64 | File size |
created_time |
uint64 | Creation timestamp |
access_time |
uint64 | Last access timestamp |
modified_time |
uint64 | Modification timestamp |
change_time |
uint64 | Metadata change timestamp |
recycled_time |
uint64 | Trash timestamp |
sync_id |
uint64 | Sync cursor position |
max_id |
uint64 | Max known sync ID |
hash |
string | Content hash |
owner |
string | File owner |
permanent_link |
string | Permanent link |
content_type |
string | MIME type |
removed |
bool | In trash |
encrypted |
bool | Encrypted |
starred |
bool | Starred |
shared |
bool | Shared |
adv_shared |
bool | Advanced sharing enabled |
transient |
bool | Transient/temporary |
disable_download |
bool | Download disabled |
capabilities.* |
bool | can_preview, can_read, can_write, can_delete, can_rename, can_comment, can_share, can_encrypt, can_organize, can_download, can_sync, can_lock, can_auto_lock |
labels |
array | [{label_id, color}, ...] |
shared_with |
array | [{permission_id, type, nickname, display_name, role, inherited}, ...] |
node_locking |
object | {is_locked, is_auto_lock, lock_id, lock_user, lock_time} |
adv_shared_info |
object | {has_password, due_date} |
revisions |
array | Version history |
properties |
object | Custom properties |
app_properties |
object | App-specific properties |
Available filter criteria for list and list_v2:
| Field | Type | Default | Description |
|---|---|---|---|
limit |
uint64 | 50 | Max results per page |
offset |
uint64 | 0 | Pagination offset |
cursor |
uint64 | 0 | Cursor-based pagination |
sort_by |
string | — | Sort field |
sort_direction |
string | — | "asc" or "desc" |
file_type |
string | — | "file", "dir", "folder" |
keyword |
string | — | Search keyword |
label_id |
string | — | Filter by label |
starred |
bool | false | Only starred items |
list_removed |
bool | false | Include trashed items |
include_transient |
bool | false | Include transient items |
extensions |
array | — | File extension filter |
version_ctime_upper_bound |
uint64 | — | Created before |
version_ctime_lower_bound |
uint64 | — | Created after |
version_mtime_upper_bound |
uint64 | — | Modified before |
version_mtime_lower_bound |
uint64 | — | Modified after |
version_file_size_upper_bound |
uint64 | — | Smaller than |
version_file_size_lower_bound |
uint64 | — | Larger than |
A specialized listing action used by the sync engine. Unlike list/list_v2, it returns all children in a single response with no pagination, and supports a cursor for efficient change detection.
Used by: ThreeWayMergeHelper::GetServerFileList (for team folders at root) and ListEventHandler::GetSyncToDeviceListCursor.
Branch logic in GetServerFileList:
- Team folder at root path (
/): uses"list_sync_to_device" - Flag at
this+0x71bset: uses"query_node"instead - Default: uses
"list"
Two modes:
- Full listing (initial sync / no cursor): Omit the
cursorfield. Server returns all children.
→ { "_action": "list_sync_to_device", "list_dir_only": false, "merge_local": false, "include_node_locking": true }
← { "node_list": [...], "cursor": "opaque_token_abc123" }- Change detection (subsequent syncs, requires server build >= 10238): Send the stored cursor. Compare returned cursor — if identical, nothing changed.
→ { "_action": "list_sync_to_device", "cursor": "opaque_token_abc123" }
← { "cursor": "opaque_token_abc123" } // unchanged — skip listing
← { "cursor": "opaque_token_def456" } // changed — do a full listingThe field name is "cursor" (string type). For initial bulk download, simply omit it or send empty string.
1. Auth: _action="auth" → session + server_id
2. Server info: _action="query_server_info" → database_restore_id
3. List shares: _action="list_team_folder" → view_list[].view_id
4. For each view: _action="list" path="/" → node_list[]
5. For each dir: _action="list" path="/subdir" → recurse
6. For each file: _action="get_file_info" (if full metadata needed)
For sync clients, prefer list_sync_to_device (no pagination, cursor change detection) over list (paginated, no change detection).
| Action String | SCMD | Purpose |
|---|---|---|
download |
SCMD_REQUEST | Download a file |
batch_download |
SCMD_REQUEST | Download multiple files as archive |
upload |
SCMD_REQUEST | Upload a file |
upload_from_dsm |
SCMD_REQUEST | Upload from DSM path |
batch_remove |
SCMD_REQUEST | Delete files |
batch_move |
SCMD_REQUEST | Move files |
batch_copy |
SCMD_REQUEST | Copy files |
batch_restore |
SCMD_REQUEST | Restore files from trash |
restore |
SCMD_REQUEST | Restore a file version |
force_current_version |
SCMD_REQUEST | Force version |
| Action String | Purpose |
|---|---|
get_file_info |
Get file metadata |
update_file_info |
Update file metadata |
get_node_info |
Get node info by ID |
query_node |
Query node details |
get_file_id |
Get file ID from path |
list_version |
List file versions |
get_thumbnail |
Get file thumbnail |
get_photo_metadata |
Get photo EXIF data |
| Action String | Purpose |
|---|---|
share_link |
Get/set share link |
get_link |
Get sharing link |
get_advance_sharing |
Get advanced sharing settings |
create_advance_sharing |
Create advanced sharing |
update_advance_sharing |
Update advanced sharing |
delete_advance_sharing |
Remove advanced sharing |
list_sharing |
List all shares |
get_sharing_permission |
Get sharing permissions |
update_sharing_permission |
Update sharing permissions |
list_shared_with_me |
List files shared with me |
list_shared_with_others |
List files shared by me |
| Action String | Purpose |
|---|---|
connect |
Connection handshake |
encrypt_channel |
SSL upgrade |
query_user_info |
Query user information |
query_server_info |
Query server information |
list_settings |
List server settings |
update_settings |
Update settings |
list_member_profile |
List team member profiles |
get_portal_link |
Get web portal URL |
pull_event |
Pull sync events |
get_event_count |
Get pending event count |
get_max_sync_id |
Get current sync watermark |
| Action String | Purpose |
|---|---|
edit_locking_on_node |
Lock/unlock a file |
list_node_locking |
List locked files |
pull_node_locking_event |
Pull lock change events |
request_unlock |
Request file unlock from another user |
sync_node_locking |
Sync lock state |
| Action String | Purpose |
|---|---|
create_label |
Create a label |
edit_label_on_node |
Apply label to file |
list_label |
List all labels |
list_labelled |
List files with label |
edit_star_on_node |
Star/unstar a file |
list_starred |
List starred files |
Errors in the modern protocol layer are returned as:
{
"error": {
"code": 4097,
"reason": "Invalid session"
}
}| Range | Category | Client Error |
|---|---|---|
| 0x1000-0x1FFF | Authentication | -6 |
| 0x2000-0x2FFF | Permission | -6 |
| 0x3000-0x3FFF | Filesystem/path | -18 |
| 0x4000-0x4FFF | Session | -3 |
| 0x5000-0x5FFF | Resource limits | -10 |
| 0x6000-0x6FFF | General | -3 |
| 0x7000-0x7FFF | Feature | -3 |
| 0x8000-0x8FFF | Feature (alt) | -1 |
| 0xD000-0xDFFF | Drive-specific | -1 |
| Server Code | Meaning |
|---|---|
| 0x1002 | Invalid password |
| 0x1003 | User disabled |
| 0x1007 | Password expired |
| 0x100D | OTP required |
| 0x100E | OTP invalid |
| 0x2002 | Access denied |
| 0x3002 | Invalid view ID |
| 0x3003 | Invalid path in view |
| 0x3004 | Name conflict |
| 0x3005 | Path too long |
| 0x3008 | Disk full |
| 0x4001 | Invalid session |
| 0x4002 | Session expired |
| 0x4003 | Session wiped |
| 0x4004 | Version too low |
| 0x7001 | Feature unavailable |
| 0x8001 | Feature unavailable (alt) |
| 0xD001 | Drive-specific error |
| 0xD002 | Drive-specific error (alt) |
To implement a basic download client:
- TCP Connect to
<server>:6690 - SSL Upgrade: Send
SCMD_REQUEST_SSL_2withencrypt_channelaction → upgrade to TLS - Connect: Send
SCMD_REQUEST_CONNECTwithconnectaction, session, client info → receivealiveinterval - Download: Send
SCMD_REQUESTwithdownloadaction, path, view_id → receive file data
- The
sessiontoken is obtained during initial authentication (via the UI/web interface) - It's stored in
sys.sqlite→connection_table.session - The
restore_ididentifies the device and is also inconnection_table - Sessions persist across restarts
- Endianness: All integers are big-endian (network byte order) on the wire. Verified by decompiling
Channel::WriteInt<uint32_t>(at0x1004ef0d8) which usesbuf[i] = value >> (8 * (3 - i))(MSB first), andDecomposeInt<uint32_t>(at0x1004e6ef8) in PStream which does the same. Nohtonl/htons— manual shift-and-mask serialization. The magic0x25521814appears on the wire as bytes[0x25, 0x52, 0x18, 0x14]. - String encoding: UTF-8 throughout. PStream strings with
_prefix have the underscore stripped on wire. - Connection pooling: The server expects persistent connections with keep-alive
- Channel capacity: The client maintains up to 5 channels in its pool
- Worker parallelism: Up to 6 concurrent sync operations
- File Provider pipeline depth: Apple limits to 3 concurrent downloads (configurable in Info.plist
NSExtensionFileProviderDownloadPipelineDepth) - Timeout: Read header timeout is 10 seconds; general timeout is 60 seconds
The _agent block must include:
{
"platform": "mac", // or "windows", "linux"
"type": "drive", // "drive" or "drive_backup"
"device_uuid": "<uuid>", // unique device identifier
"restore_id": "<server_ds_id>",
"version": {
"major": 4, "minor": 0, "mini": 0, "build": 17889
}
}The protocol version is 0x46 (70 decimal), meaning major 7, minor 0. The ProtoCheck function validates version compatibility:
int ProtoCheck(int version) {
int major = (version - version % 10) / 10;
if (major > 7) return 3; // too new, incompatible
if (version < 70) return 2; // too old
if (version == 70) return 0; // exact match
return 1; // compatible, newer minor
}This means:
- Version 70 (7.0): exact match, fully compatible
- Versions 71-79 (7.1-7.9): compatible, client newer
- Versions < 70: too old, rejected
- Versions >= 80: too new, rejected (different major)
Debug strings in the binary reveal the original source structure:
synosyncfolder/lib/protocol/proto-common.cpp — Section encoding/decoding
synosyncfolder/lib/protocol/proto-client.cpp — ProtocolClient (send/recv/connect)
synosyncfolder/lib/protocol/proto-ui.cpp — UI-facing CloudStation protocol
synosyncfolder/lib/stream/stream.cpp — PStream serialization
synosyncfolder/lib/channel/channel.cpp — TCP channel management
synosyncfolder/lib/connection/connection.cpp — Connection pool
synosyncfolder/lib/connection/conn-finder.cpp — Connection discovery (SmartDNS/QuickConnect)
synosyncfolder/daemon/daemon-impl.cpp — Main daemon implementation
synosyncfolder/daemon/worker_mgr.cpp — Worker manager
synosyncfolder/handler/upload-local-handler.cpp — Upload handler
synosyncfolder/handler/download-remote-handler.cpp — Download handler
synosyncfolder/handler/handler-helper.cpp — Handler utilities
The client supports multiple connection methods, tried in order:
- Direct connection: Connect to
server_ip:6690directly - SmartDNS LAN: Resolve hostname via local DNS, try IPv4 and IPv6
- QuickConnect: Use Synology's QuickConnect relay service to find the server
- Relay tunnel: Fall back to Synology relay servers for NAT traversal
- Punch daemon: UDP hole-punching via
punchd(localhost port, configurable)
The connection finder (conn-finder.cpp) tries these in stages and uses the first successful one ("early-stopping" on success).