@mt-tl/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/auth/handshake.d.ts +35 -0
  4. package/dist/auth/handshake.d.ts.map +1 -0
  5. package/dist/auth/handshake.js +208 -0
  6. package/dist/auth/handshake.js.map +1 -0
  7. package/dist/auth/nonce-store.d.ts +22 -0
  8. package/dist/auth/nonce-store.d.ts.map +1 -0
  9. package/dist/auth/nonce-store.js +23 -0
  10. package/dist/auth/nonce-store.js.map +1 -0
  11. package/dist/bootstrap.d.ts +36 -0
  12. package/dist/bootstrap.d.ts.map +1 -0
  13. package/dist/bootstrap.js +82 -0
  14. package/dist/bootstrap.js.map +1 -0
  15. package/dist/config.d.ts +103 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +2 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/core/context.d.ts +57 -0
  20. package/dist/core/context.d.ts.map +1 -0
  21. package/dist/core/context.js +27 -0
  22. package/dist/core/context.js.map +1 -0
  23. package/dist/core/errors.d.ts +33 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +47 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/index.d.ts +5 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/index.js +5 -0
  30. package/dist/core/index.js.map +1 -0
  31. package/dist/core/rpc.d.ts +83 -0
  32. package/dist/core/rpc.d.ts.map +1 -0
  33. package/dist/core/rpc.js +102 -0
  34. package/dist/core/rpc.js.map +1 -0
  35. package/dist/core/updates.d.ts +56 -0
  36. package/dist/core/updates.d.ts.map +1 -0
  37. package/dist/core/updates.js +34 -0
  38. package/dist/core/updates.js.map +1 -0
  39. package/dist/create-server.d.ts +89 -0
  40. package/dist/create-server.d.ts.map +1 -0
  41. package/dist/create-server.js +109 -0
  42. package/dist/create-server.js.map +1 -0
  43. package/dist/crypto/aes-ige.d.ts +3 -0
  44. package/dist/crypto/aes-ige.d.ts.map +1 -0
  45. package/dist/crypto/aes-ige.js +55 -0
  46. package/dist/crypto/aes-ige.js.map +1 -0
  47. package/dist/crypto/dh.d.ts +21 -0
  48. package/dist/crypto/dh.d.ts.map +1 -0
  49. package/dist/crypto/dh.js +99 -0
  50. package/dist/crypto/dh.js.map +1 -0
  51. package/dist/crypto/hashes.d.ts +6 -0
  52. package/dist/crypto/hashes.d.ts.map +1 -0
  53. package/dist/crypto/hashes.js +14 -0
  54. package/dist/crypto/hashes.js.map +1 -0
  55. package/dist/crypto/msg-key.d.ts +15 -0
  56. package/dist/crypto/msg-key.d.ts.map +1 -0
  57. package/dist/crypto/msg-key.js +24 -0
  58. package/dist/crypto/msg-key.js.map +1 -0
  59. package/dist/crypto/rsa.d.ts +27 -0
  60. package/dist/crypto/rsa.d.ts.map +1 -0
  61. package/dist/crypto/rsa.js +50 -0
  62. package/dist/crypto/rsa.js.map +1 -0
  63. package/dist/dispatch/dispatcher.d.ts +72 -0
  64. package/dist/dispatch/dispatcher.d.ts.map +1 -0
  65. package/dist/dispatch/dispatcher.js +503 -0
  66. package/dist/dispatch/dispatcher.js.map +1 -0
  67. package/dist/dispatch/forwarders/in-process.d.ts +12 -0
  68. package/dist/dispatch/forwarders/in-process.d.ts.map +1 -0
  69. package/dist/dispatch/forwarders/in-process.js +15 -0
  70. package/dist/dispatch/forwarders/in-process.js.map +1 -0
  71. package/dist/dispatch/forwarders/print.d.ts +14 -0
  72. package/dist/dispatch/forwarders/print.d.ts.map +1 -0
  73. package/dist/dispatch/forwarders/print.js +23 -0
  74. package/dist/dispatch/forwarders/print.js.map +1 -0
  75. package/dist/dispatch/rpc-forwarder.d.ts +14 -0
  76. package/dist/dispatch/rpc-forwarder.d.ts.map +1 -0
  77. package/dist/dispatch/rpc-forwarder.js +2 -0
  78. package/dist/dispatch/rpc-forwarder.js.map +1 -0
  79. package/dist/dispatch/types.d.ts +32 -0
  80. package/dist/dispatch/types.d.ts.map +1 -0
  81. package/dist/dispatch/types.js +23 -0
  82. package/dist/dispatch/types.js.map +1 -0
  83. package/dist/gateway.d.ts +57 -0
  84. package/dist/gateway.d.ts.map +1 -0
  85. package/dist/gateway.js +150 -0
  86. package/dist/gateway.js.map +1 -0
  87. package/dist/index.d.ts +7 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +25 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/lib.d.ts +12 -0
  92. package/dist/lib.d.ts.map +1 -0
  93. package/dist/lib.js +13 -0
  94. package/dist/lib.js.map +1 -0
  95. package/dist/server/message-pipeline.d.ts +57 -0
  96. package/dist/server/message-pipeline.d.ts.map +1 -0
  97. package/dist/server/message-pipeline.js +199 -0
  98. package/dist/server/message-pipeline.js.map +1 -0
  99. package/dist/session/inbound-tracker.d.ts +118 -0
  100. package/dist/session/inbound-tracker.d.ts.map +1 -0
  101. package/dist/session/inbound-tracker.js +170 -0
  102. package/dist/session/inbound-tracker.js.map +1 -0
  103. package/dist/session/message-id.d.ts +19 -0
  104. package/dist/session/message-id.d.ts.map +1 -0
  105. package/dist/session/message-id.js +30 -0
  106. package/dist/session/message-id.js.map +1 -0
  107. package/dist/session/salts.d.ts +51 -0
  108. package/dist/session/salts.d.ts.map +1 -0
  109. package/dist/session/salts.js +132 -0
  110. package/dist/session/salts.js.map +1 -0
  111. package/dist/session/session-manager.d.ts +18 -0
  112. package/dist/session/session-manager.d.ts.map +1 -0
  113. package/dist/session/session-manager.js +43 -0
  114. package/dist/session/session-manager.js.map +1 -0
  115. package/dist/storage/index.d.ts +14 -0
  116. package/dist/storage/index.d.ts.map +1 -0
  117. package/dist/storage/index.js +16 -0
  118. package/dist/storage/index.js.map +1 -0
  119. package/dist/storage/memory.d.ts +3 -0
  120. package/dist/storage/memory.d.ts.map +1 -0
  121. package/dist/storage/memory.js +91 -0
  122. package/dist/storage/memory.js.map +1 -0
  123. package/dist/storage/mongo.d.ts +3 -0
  124. package/dist/storage/mongo.d.ts.map +1 -0
  125. package/dist/storage/mongo.js +175 -0
  126. package/dist/storage/mongo.js.map +1 -0
  127. package/dist/storage/types.d.ts +85 -0
  128. package/dist/storage/types.d.ts.map +1 -0
  129. package/dist/storage/types.js +3 -0
  130. package/dist/storage/types.js.map +1 -0
  131. package/dist/testkit.d.ts +11 -0
  132. package/dist/testkit.d.ts.map +1 -0
  133. package/dist/testkit.js +17 -0
  134. package/dist/testkit.js.map +1 -0
  135. package/dist/tl/codec.d.ts +37 -0
  136. package/dist/tl/codec.d.ts.map +1 -0
  137. package/dist/tl/codec.js +297 -0
  138. package/dist/tl/codec.js.map +1 -0
  139. package/dist/tl/layered-registry.d.ts +29 -0
  140. package/dist/tl/layered-registry.d.ts.map +1 -0
  141. package/dist/tl/layered-registry.js +118 -0
  142. package/dist/tl/layered-registry.js.map +1 -0
  143. package/dist/tl/protocol.d.ts +126 -0
  144. package/dist/tl/protocol.d.ts.map +1 -0
  145. package/dist/tl/protocol.js +22 -0
  146. package/dist/tl/protocol.js.map +1 -0
  147. package/dist/tl/reader.d.ts +30 -0
  148. package/dist/tl/reader.d.ts.map +1 -0
  149. package/dist/tl/reader.js +87 -0
  150. package/dist/tl/reader.js.map +1 -0
  151. package/dist/tl/registry.d.ts +39 -0
  152. package/dist/tl/registry.d.ts.map +1 -0
  153. package/dist/tl/registry.js +52 -0
  154. package/dist/tl/registry.js.map +1 -0
  155. package/dist/tl/writer.d.ts +24 -0
  156. package/dist/tl/writer.d.ts.map +1 -0
  157. package/dist/tl/writer.js +95 -0
  158. package/dist/tl/writer.js.map +1 -0
  159. package/dist/transport/connection-registry.d.ts +31 -0
  160. package/dist/transport/connection-registry.d.ts.map +1 -0
  161. package/dist/transport/connection-registry.js +84 -0
  162. package/dist/transport/connection-registry.js.map +1 -0
  163. package/dist/transport/connection.d.ts +62 -0
  164. package/dist/transport/connection.d.ts.map +1 -0
  165. package/dist/transport/connection.js +77 -0
  166. package/dist/transport/connection.js.map +1 -0
  167. package/dist/transport/framing.d.ts +39 -0
  168. package/dist/transport/framing.d.ts.map +1 -0
  169. package/dist/transport/framing.js +212 -0
  170. package/dist/transport/framing.js.map +1 -0
  171. package/dist/transport/proxy-protocol.d.ts +23 -0
  172. package/dist/transport/proxy-protocol.d.ts.map +1 -0
  173. package/dist/transport/proxy-protocol.js +108 -0
  174. package/dist/transport/proxy-protocol.js.map +1 -0
  175. package/dist/transport/server-common.d.ts +27 -0
  176. package/dist/transport/server-common.d.ts.map +1 -0
  177. package/dist/transport/server-common.js +27 -0
  178. package/dist/transport/server-common.js.map +1 -0
  179. package/dist/transport/tcp-server.d.ts +26 -0
  180. package/dist/transport/tcp-server.d.ts.map +1 -0
  181. package/dist/transport/tcp-server.js +91 -0
  182. package/dist/transport/tcp-server.js.map +1 -0
  183. package/dist/transport/ws-server.d.ts +19 -0
  184. package/dist/transport/ws-server.d.ts.map +1 -0
  185. package/dist/transport/ws-server.js +78 -0
  186. package/dist/transport/ws-server.js.map +1 -0
  187. package/dist/update-publisher.d.ts +34 -0
  188. package/dist/update-publisher.d.ts.map +1 -0
  189. package/dist/update-publisher.js +29 -0
  190. package/dist/update-publisher.js.map +1 -0
  191. package/dist/updates/mongo-update-log.d.ts +13 -0
  192. package/dist/updates/mongo-update-log.d.ts.map +1 -0
  193. package/dist/updates/mongo-update-log.js +39 -0
  194. package/dist/updates/mongo-update-log.js.map +1 -0
  195. package/dist/updates/presence-binder.d.ts +29 -0
  196. package/dist/updates/presence-binder.d.ts.map +1 -0
  197. package/dist/updates/presence-binder.js +36 -0
  198. package/dist/updates/presence-binder.js.map +1 -0
  199. package/dist/updates/presence.d.ts +31 -0
  200. package/dist/updates/presence.d.ts.map +1 -0
  201. package/dist/updates/presence.js +44 -0
  202. package/dist/updates/presence.js.map +1 -0
  203. package/dist/updates/push.d.ts +25 -0
  204. package/dist/updates/push.d.ts.map +1 -0
  205. package/dist/updates/push.js +72 -0
  206. package/dist/updates/push.js.map +1 -0
  207. package/dist/updates/redis-bus.d.ts +45 -0
  208. package/dist/updates/redis-bus.d.ts.map +1 -0
  209. package/dist/updates/redis-bus.js +59 -0
  210. package/dist/updates/redis-bus.js.map +1 -0
  211. package/dist/updates/redis-presence.d.ts +43 -0
  212. package/dist/updates/redis-presence.d.ts.map +1 -0
  213. package/dist/updates/redis-presence.js +65 -0
  214. package/dist/updates/redis-presence.js.map +1 -0
  215. package/dist/updates/render.d.ts +16 -0
  216. package/dist/updates/render.d.ts.map +1 -0
  217. package/dist/updates/render.js +46 -0
  218. package/dist/updates/render.js.map +1 -0
  219. package/dist/updates/router.d.ts +27 -0
  220. package/dist/updates/router.d.ts.map +1 -0
  221. package/dist/updates/router.js +36 -0
  222. package/dist/updates/router.js.map +1 -0
  223. package/dist/updates/types.d.ts +23 -0
  224. package/dist/updates/types.d.ts.map +1 -0
  225. package/dist/updates/types.js +2 -0
  226. package/dist/updates/types.js.map +1 -0
  227. package/dist/updates/update-bus.d.ts +29 -0
  228. package/dist/updates/update-bus.d.ts.map +1 -0
  229. package/dist/updates/update-bus.js +24 -0
  230. package/dist/updates/update-bus.js.map +1 -0
  231. package/dist/util/bytes.d.ts +12 -0
  232. package/dist/util/bytes.d.ts.map +1 -0
  233. package/dist/util/bytes.js +46 -0
  234. package/dist/util/bytes.js.map +1 -0
  235. package/package.json +84 -0
  236. package/src/auth/handshake.ts +262 -0
  237. package/src/auth/nonce-store.ts +39 -0
  238. package/src/bootstrap.ts +114 -0
  239. package/src/config.ts +103 -0
  240. package/src/core/context.ts +94 -0
  241. package/src/core/errors.ts +52 -0
  242. package/src/core/index.ts +4 -0
  243. package/src/core/rpc.ts +165 -0
  244. package/src/core/updates.ts +69 -0
  245. package/src/create-server.ts +181 -0
  246. package/src/crypto/aes-ige.ts +57 -0
  247. package/src/crypto/dh.ts +101 -0
  248. package/src/crypto/hashes.ts +17 -0
  249. package/src/crypto/msg-key.ts +29 -0
  250. package/src/crypto/rsa.ts +70 -0
  251. package/src/dispatch/dispatcher.ts +586 -0
  252. package/src/dispatch/forwarders/in-process.ts +14 -0
  253. package/src/dispatch/forwarders/print.ts +22 -0
  254. package/src/dispatch/rpc-forwarder.ts +15 -0
  255. package/src/dispatch/types.ts +60 -0
  256. package/src/gateway.ts +214 -0
  257. package/src/index.ts +53 -0
  258. package/src/lib.ts +24 -0
  259. package/src/server/message-pipeline.ts +256 -0
  260. package/src/session/inbound-tracker.ts +221 -0
  261. package/src/session/message-id.ts +43 -0
  262. package/src/session/salts.ts +162 -0
  263. package/src/session/session-manager.ts +66 -0
  264. package/src/storage/index.ts +26 -0
  265. package/src/storage/memory.ts +101 -0
  266. package/src/storage/mongo.ts +215 -0
  267. package/src/storage/types.ts +92 -0
  268. package/src/testkit.ts +19 -0
  269. package/src/tl/codec.ts +292 -0
  270. package/src/tl/layered-registry.ts +132 -0
  271. package/src/tl/protocol.ts +146 -0
  272. package/src/tl/reader.ts +99 -0
  273. package/src/tl/registry.ts +78 -0
  274. package/src/tl/writer.ts +104 -0
  275. package/src/transport/connection-registry.ts +91 -0
  276. package/src/transport/connection.ts +113 -0
  277. package/src/transport/framing.ts +223 -0
  278. package/src/transport/proxy-protocol.ts +109 -0
  279. package/src/transport/server-common.ts +49 -0
  280. package/src/transport/tcp-server.ts +102 -0
  281. package/src/transport/ws-server.ts +94 -0
  282. package/src/update-publisher.ts +47 -0
  283. package/src/updates/mongo-update-log.ts +49 -0
  284. package/src/updates/presence-binder.ts +51 -0
  285. package/src/updates/presence.ts +61 -0
  286. package/src/updates/push.ts +90 -0
  287. package/src/updates/redis-bus.ts +86 -0
  288. package/src/updates/redis-presence.ts +87 -0
  289. package/src/updates/render.ts +53 -0
  290. package/src/updates/router.ts +52 -0
  291. package/src/updates/types.ts +24 -0
  292. package/src/updates/update-bus.ts +49 -0
  293. package/src/util/bytes.ts +49 -0
@@ -0,0 +1,113 @@
1
+ import { noopLogger, type Logger } from '@mt-tl/tl'
2
+ import { Framing } from './framing.js'
3
+ import { nextMessageId, nextSeqNo, type MsgIdState } from '../session/message-id.js'
4
+ import { InboundTracker } from '../session/inbound-tracker.js'
5
+
6
+ /**
7
+ * Per-connection state. Holds transport framing, the negotiated TL layer,
8
+ * auth-key/session binding (filled after the handshake and first encrypted
9
+ * message), and outgoing message-id/seq counters.
10
+ */
11
+ export interface ConnectionCtx extends MsgIdState {
12
+ connectionId: number
13
+ remoteAddress?: string
14
+ apiLayer: number
15
+
16
+ authKeyId?: bigint
17
+ authKey?: Buffer
18
+ sessionId?: bigint
19
+ uniqueId?: bigint
20
+ serverSalt?: bigint
21
+
22
+ // Captured from `initConnection` (and persisted onto the auth key's meta).
23
+ apiId?: number
24
+ deviceModel?: string
25
+ systemVersion?: string
26
+ appVersion?: string
27
+ systemLangCode?: string
28
+ langCode?: string
29
+ /** Bound subject (internal user id) once the auth key is authorized. */
30
+ subject?: string
31
+
32
+ /** Set when the client wrapped a query in `invokeWithoutUpdates` — this
33
+ * connection is excluded from server-push delivery. */
34
+ noUpdates?: boolean
35
+ }
36
+
37
+ export class Connection {
38
+ readonly id: number
39
+ /** Per-connection logger (carrier-scoped child); also passed to framing. */
40
+ readonly log: Logger
41
+ readonly framing: Framing
42
+ /** Inbound msg_id/seqno validation + received-message state (per session). */
43
+ readonly tracker = new InboundTracker()
44
+ ctx: ConnectionCtx
45
+ closed = false
46
+ private tail: Promise<void> = Promise.resolve()
47
+ /** Idle-disconnect window (ms) requested via `ping_delay_disconnect`; 0 = none. */
48
+ private disconnectMs = 0
49
+ private disconnectTimer?: ReturnType<typeof setTimeout>
50
+
51
+ constructor(
52
+ id: number,
53
+ private readonly transportSend: (bytes: Buffer) => void,
54
+ private readonly transportClose: () => void,
55
+ remoteAddress?: string,
56
+ defaultLayer = 204,
57
+ log: Logger = noopLogger,
58
+ ) {
59
+ this.id = id
60
+ this.log = log
61
+ this.framing = new Framing(log)
62
+ this.ctx = {
63
+ connectionId: id,
64
+ remoteAddress,
65
+ apiLayer: defaultLayer,
66
+ lastMessageId: null,
67
+ messageSeqNo: 0,
68
+ }
69
+ }
70
+
71
+ /** Frame a fully-built MTProto packet (plaintext or encrypted) and send it. */
72
+ send(packet: Buffer): void {
73
+ if (this.closed) return
74
+ this.transportSend(this.framing.frame(packet))
75
+ }
76
+
77
+ nextMessageId(isNotification = false): bigint {
78
+ return nextMessageId(this.ctx, isNotification)
79
+ }
80
+
81
+ nextSeqNo(contentRelated = true): number {
82
+ return nextSeqNo(this.ctx, contentRelated)
83
+ }
84
+
85
+ /** Serialize async work per connection so messages are processed in order. */
86
+ enqueue(fn: () => void | Promise<void>): void {
87
+ this.tail = this.tail.then(fn).catch(() => {})
88
+ }
89
+
90
+ /**
91
+ * Arm (or re-arm) the `ping_delay_disconnect` idle timer: close the connection
92
+ * after `delaySec` seconds of inactivity. A delay of 0 disarms it.
93
+ */
94
+ armDisconnect(delaySec: number): void {
95
+ this.disconnectMs = Math.max(0, Math.floor(delaySec)) * 1000
96
+ this.resetDisconnect()
97
+ }
98
+
99
+ /** Reset the idle timer on activity. No-op unless armed via {@link armDisconnect}. */
100
+ resetDisconnect(): void {
101
+ if (this.disconnectTimer) clearTimeout(this.disconnectTimer)
102
+ if (!this.disconnectMs) return
103
+ this.disconnectTimer = setTimeout(() => this.close(), this.disconnectMs)
104
+ if (typeof this.disconnectTimer.unref === 'function') this.disconnectTimer.unref()
105
+ }
106
+
107
+ close(): void {
108
+ if (this.closed) return
109
+ this.closed = true
110
+ if (this.disconnectTimer) clearTimeout(this.disconnectTimer)
111
+ this.transportClose()
112
+ }
113
+ }
@@ -0,0 +1,223 @@
1
+ import { createDecipheriv } from 'node:crypto'
2
+ import CRC32 from 'crc-32'
3
+ import { noopLogger, type Logger } from '@mt-tl/tl'
4
+ import { calculatePadding } from '../crypto/dh.js'
5
+
6
+ type Decipher = ReturnType<typeof createDecipheriv>
7
+
8
+ /**
9
+ * MTProto transport framing. The same four modes the existing server supports
10
+ * are delivered identically over TCP or inside binary WebSocket frames, so the
11
+ * gateway feeds raw bytes here regardless of carrier.
12
+ *
13
+ * Modes: abridged (0xef), intermediate (0xeeeeeeee), full, and obfuscated
14
+ * (a 64-byte AES-CTR header that wraps abridged/intermediate). Detection mirrors
15
+ * `server.js` exactly for wire-compatibility.
16
+ */
17
+ export type InnerMode = 'abridged' | 'intermediate' | 'full'
18
+ export type Mode = InnerMode | 'obfuscated'
19
+
20
+ function looksLikeTcpFullStreamStart(buf: Buffer): boolean {
21
+ const len = buf.readUInt32LE(0)
22
+ if (len > 65536 || buf.length < len) return false
23
+ return buf.readUInt32LE(len - 8) === 0
24
+ }
25
+
26
+ export class Framing {
27
+ mode?: Mode
28
+ private inner?: InnerMode
29
+ /** Plaintext queue from which inner packets are read. */
30
+ private queue = Buffer.alloc(0)
31
+ /** Pre-detection accumulation. */
32
+ private detectBuf = Buffer.alloc(0)
33
+ private sequenceIn = 0
34
+ private sequenceOut = 0
35
+ private decryptor?: Decipher
36
+ private encryptor?: Decipher
37
+
38
+ constructor(private readonly log: Logger = noopLogger) {}
39
+
40
+ /** Feed received bytes; returns any complete packets that became available. */
41
+ feed(chunk: Buffer): Buffer[] {
42
+ if (this.mode === undefined) {
43
+ this.detectBuf = Buffer.concat([this.detectBuf, chunk])
44
+ if (!this.detect()) return []
45
+ } else if (this.mode === 'obfuscated') {
46
+ this.queue = Buffer.concat([this.queue, this.decryptor!.update(chunk)])
47
+ } else {
48
+ this.queue = Buffer.concat([this.queue, chunk])
49
+ }
50
+ return this.drain()
51
+ }
52
+
53
+ /** Frame an outgoing packet for the negotiated mode. */
54
+ frame(packet: Buffer): Buffer {
55
+ const eff = this.effectiveMode()
56
+ const framed = this.frameInner(eff, packet)
57
+ return this.mode === 'obfuscated' ? this.encryptor!.update(framed) : framed
58
+ }
59
+
60
+ private effectiveMode(): InnerMode {
61
+ if (this.mode === 'obfuscated') return this.inner!
62
+ return this.mode as InnerMode
63
+ }
64
+
65
+ private detect(): boolean {
66
+ const buf = this.detectBuf
67
+ if (buf.length < 4) return false
68
+
69
+ if (buf.readUInt8(0) === 0xef) {
70
+ this.mode = 'abridged'
71
+ this.queue = Buffer.from(buf.subarray(1))
72
+ return true
73
+ }
74
+ if (buf.readUInt32LE(0) === 0xeeeeeeee) {
75
+ this.mode = 'intermediate'
76
+ this.queue = Buffer.from(buf.subarray(4))
77
+ return true
78
+ }
79
+ if (buf.length < 8) return false
80
+ if (buf.readUInt32LE(4) === 0 || looksLikeTcpFullStreamStart(buf)) {
81
+ this.mode = 'full'
82
+ this.queue = Buffer.from(buf)
83
+ return true
84
+ }
85
+ if (buf.length < 64) return false
86
+
87
+ // Obfuscated: 64-byte header sets up AES-CTR streams.
88
+ const header = Buffer.from(buf.subarray(0, 64))
89
+ const rev = Buffer.from(header).reverse()
90
+ this.decryptor = createDecipheriv('aes-256-ctr', header.subarray(8, 40), header.subarray(40, 56))
91
+ this.encryptor = createDecipheriv('aes-256-ctr', rev.subarray(8, 40), rev.subarray(40, 56))
92
+ const decHeader = this.decryptor.update(header)
93
+ if (this.log.isLevelEnabled('trace')) {
94
+ this.log.trace('framing.obfuscated', {
95
+ tag: decHeader.subarray(56, 60).toString('hex'),
96
+ rawHead: header.subarray(0, 16).toString('hex'),
97
+ decHead: decHeader.subarray(0, 16).toString('hex'),
98
+ })
99
+ }
100
+ switch (decHeader[56]) {
101
+ case 0xdd:
102
+ case 0xee:
103
+ this.inner = 'intermediate'
104
+ break
105
+ case 0xef:
106
+ this.inner = 'abridged'
107
+ break
108
+ default:
109
+ throw new Error(
110
+ `obfuscated: cannot determine inner mode (tag ${decHeader.subarray(56, 60).toString('hex')})`,
111
+ )
112
+ }
113
+ this.mode = 'obfuscated'
114
+ this.queue = this.decryptor.update(Buffer.from(buf.subarray(64)))
115
+ if (this.log.isLevelEnabled('trace')) {
116
+ this.log.trace('framing.detected', {
117
+ mode: this.mode,
118
+ inner: this.inner,
119
+ queued: this.queue.length,
120
+ })
121
+ }
122
+ return true
123
+ }
124
+
125
+ private drain(): Buffer[] {
126
+ const out: Buffer[] = []
127
+ for (;;) {
128
+ const p = this.readOne()
129
+ if (p === undefined) break
130
+ out.push(p)
131
+ }
132
+ return out
133
+ }
134
+
135
+ private readOne(): Buffer | undefined {
136
+ switch (this.effectiveMode()) {
137
+ case 'abridged':
138
+ return this.readAbridged()
139
+ case 'intermediate':
140
+ return this.readIntermediate()
141
+ case 'full':
142
+ return this.readFull()
143
+ }
144
+ }
145
+
146
+ private readAbridged(): Buffer | undefined {
147
+ const q = this.queue
148
+ if (q.length < 1) return undefined
149
+ let len = q.readUInt8(0)
150
+ let shift = 1
151
+ if (len === 0x7f) {
152
+ if (q.length < 4) return undefined
153
+ len = (q.readUInt8(1) | (q.readUInt8(2) << 8) | (q.readUInt8(3) << 16)) << 2
154
+ shift = 4
155
+ } else {
156
+ len <<= 2
157
+ }
158
+ if (q.length < len + shift) return undefined
159
+ const packet = Buffer.from(q.subarray(shift, shift + len))
160
+ this.queue = q.subarray(shift + len)
161
+ return packet
162
+ }
163
+
164
+ private readIntermediate(): Buffer | undefined {
165
+ const q = this.queue
166
+ if (q.length < 4) return undefined
167
+ const len = q.readUInt32LE(0)
168
+ if (q.length < len + 4) return undefined
169
+ const packet = Buffer.from(q.subarray(4, 4 + len))
170
+ this.queue = q.subarray(4 + len)
171
+ return packet
172
+ }
173
+
174
+ private readFull(): Buffer | undefined {
175
+ // Standard tcp_full layout: [len(4)][seq(4)][payload][crc(4)], len = payload + 12.
176
+ const q = this.queue
177
+ if (q.length < 4) return undefined
178
+ const len = q.readUInt32LE(0)
179
+ if (len < 12 || q.length < len) return undefined
180
+ const seqNo = q.readUInt32LE(4)
181
+ if (seqNo !== this.sequenceIn) {
182
+ throw new Error(`tcp_full: wrong sequence ${seqNo}, expected ${this.sequenceIn}`)
183
+ }
184
+ this.sequenceIn++
185
+ const packet = Buffer.from(q.subarray(8, len - 4))
186
+ this.queue = q.subarray(len)
187
+ return packet
188
+ }
189
+
190
+ private frameInner(mode: InnerMode, packet: Buffer): Buffer {
191
+ switch (mode) {
192
+ case 'abridged': {
193
+ const pad = Buffer.alloc(calculatePadding(packet.length, 4))
194
+ const lenValue = (packet.length + pad.length) / 4
195
+ const lenB =
196
+ lenValue < 127
197
+ ? Buffer.from([lenValue])
198
+ : Buffer.from([
199
+ 0x7f,
200
+ lenValue & 0xff,
201
+ (lenValue >> 8) & 0xff,
202
+ (lenValue >> 16) & 0xff,
203
+ ])
204
+ return Buffer.concat([lenB, packet, pad])
205
+ }
206
+ case 'intermediate': {
207
+ const lenB = Buffer.alloc(4)
208
+ lenB.writeUInt32LE(packet.length, 0)
209
+ return Buffer.concat([lenB, packet])
210
+ }
211
+ case 'full': {
212
+ const lenB = Buffer.alloc(4)
213
+ lenB.writeUInt32LE(packet.length + 12, 0)
214
+ const seqB = Buffer.alloc(4)
215
+ seqB.writeUInt32LE(this.sequenceOut++, 0)
216
+ const body = Buffer.concat([lenB, seqB, packet])
217
+ const crc = Buffer.alloc(4)
218
+ crc.writeUInt32LE(CRC32.buf(body) >>> 0, 0)
219
+ return Buffer.concat([body, crc])
220
+ }
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,109 @@
1
+ // PROXY protocol (HAProxy) header parsing for the raw-TCP carrier. When a TCP
2
+ // load balancer / proxy sits in front, it prepends a small header announcing the
3
+ // real client address before the MTProto byte stream. We parse it (v1 text and
4
+ // v2 binary) so `RpcContext.ip` reflects the client, not the proxy. Only used
5
+ // when `trustProxy` is set — the spec assumes a trusted proxy always prepends it.
6
+ // Ref: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
7
+
8
+ /** 12-byte v2 signature: `\r\n\r\n\0\r\nQUIT\n`. */
9
+ const V2_SIG = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a])
10
+ /** v1 lines start with `PROXY ` and are at most 107 bytes incl. CRLF. */
11
+ const V1_PREFIX = Buffer.from('PROXY ')
12
+ const V1_MAX = 107
13
+ /** Cap buffered bytes while a header is still incomplete (guards a slow/malicious peer). */
14
+ const MAX_HEADER = 1024
15
+
16
+ export type ProxyParse =
17
+ /** A complete header. `sourceIp` is undefined for UNKNOWN/LOCAL/UNSPEC (use the socket address). */
18
+ | { status: 'done'; sourceIp?: string; consumed: number }
19
+ /** Need more bytes — the buffer is a valid prefix of a header so far. */
20
+ | { status: 'incomplete' }
21
+ /** No PROXY header — treat the bytes as the start of the MTProto stream. */
22
+ | { status: 'absent' }
23
+
24
+ /**
25
+ * Inspects the start of a freshly-accepted TCP stream for a PROXY-protocol
26
+ * header. Returns `done` (with the parsed source IP and how many bytes the header
27
+ * occupied), `incomplete` (call again with more bytes), or `absent` (no header —
28
+ * the bytes are the MTProto stream itself).
29
+ */
30
+ export function parseProxyHeader(buf: Buffer): ProxyParse {
31
+ if (buf.length === 0) return { status: 'incomplete' }
32
+ const b0 = buf[0]!
33
+
34
+ // v2 — binary, begins with 0x0D.
35
+ if (b0 === 0x0d) {
36
+ const n = Math.min(buf.length, V2_SIG.length)
37
+ for (let i = 0; i < n; i++) if (buf[i] !== V2_SIG[i]) return { status: 'absent' }
38
+ if (buf.length < 16) return buf.length > MAX_HEADER ? { status: 'absent' } : { status: 'incomplete' }
39
+ return parseV2(buf)
40
+ }
41
+
42
+ // v1 — text, begins with 'P' (0x50) of "PROXY ".
43
+ if (b0 === 0x50) {
44
+ const crlf = buf.indexOf('\r\n')
45
+ if (crlf === -1) {
46
+ if (buf.length > V1_MAX) return { status: 'absent' }
47
+ const n = Math.min(buf.length, V1_PREFIX.length)
48
+ for (let i = 0; i < n; i++) if (buf[i] !== V1_PREFIX[i]) return { status: 'absent' }
49
+ return { status: 'incomplete' }
50
+ }
51
+ return parseV1(buf.toString('latin1', 0, crlf), crlf + 2)
52
+ }
53
+
54
+ return { status: 'absent' }
55
+ }
56
+
57
+ function parseV1(line: string, consumed: number): ProxyParse {
58
+ // "PROXY TCP4 <src> <dst> <srcPort> <dstPort>" | "PROXY UNKNOWN ..."
59
+ const parts = line.split(' ')
60
+ if (parts[0] !== 'PROXY') return { status: 'absent' }
61
+ if (parts[1] === 'TCP4' || parts[1] === 'TCP6') return { status: 'done', sourceIp: parts[2], consumed }
62
+ return { status: 'done', consumed } // UNKNOWN — no announced address
63
+ }
64
+
65
+ function parseV2(buf: Buffer): ProxyParse {
66
+ const cmd = buf[12]! & 0x0f // 0 = LOCAL (health check), 1 = PROXY
67
+ const family = buf[13]! >> 4 // 1 = AF_INET, 2 = AF_INET6
68
+ const addrLen = buf.readUInt16BE(14)
69
+ const total = 16 + addrLen
70
+ if (buf.length < total) return total > MAX_HEADER ? { status: 'absent' } : { status: 'incomplete' }
71
+
72
+ let sourceIp: string | undefined
73
+ if (cmd === 0x1) {
74
+ if (family === 0x1 && addrLen >= 12) {
75
+ sourceIp = `${buf[16]}.${buf[17]}.${buf[18]}.${buf[19]}`
76
+ } else if (family === 0x2 && addrLen >= 36) {
77
+ sourceIp = formatIpv6(buf.subarray(16, 32))
78
+ }
79
+ }
80
+ return { status: 'done', sourceIp, consumed: total }
81
+ }
82
+
83
+ /** 16 bytes → a compressed IPv6 string (longest run of zero groups → `::`). */
84
+ function formatIpv6(b: Buffer): string {
85
+ const groups: number[] = []
86
+ for (let i = 0; i < 16; i += 2) groups.push((b[i]! << 8) | b[i + 1]!)
87
+ // Find the longest run of zero groups to compress.
88
+ let bestStart = -1
89
+ let bestLen = 0
90
+ let curStart = -1
91
+ let curLen = 0
92
+ for (let i = 0; i < 8; i++) {
93
+ if (groups[i] === 0) {
94
+ if (curStart === -1) curStart = i
95
+ curLen++
96
+ if (curLen > bestLen) {
97
+ bestLen = curLen
98
+ bestStart = curStart
99
+ }
100
+ } else {
101
+ curStart = -1
102
+ curLen = 0
103
+ }
104
+ }
105
+ if (bestLen < 2) return groups.map(g => g.toString(16)).join(':')
106
+ const head = groups.slice(0, bestStart).map(g => g.toString(16))
107
+ const tail = groups.slice(bestStart + bestLen).map(g => g.toString(16))
108
+ return `${head.join(':')}::${tail.join(':')}`
109
+ }
@@ -0,0 +1,49 @@
1
+ import type { Logger } from '@mt-tl/tl'
2
+ import type { Connection } from './connection.js'
3
+
4
+ export type PacketHandler = (packet: Buffer, conn: Connection) => void | Promise<void>
5
+
6
+ export interface TransportHandlers {
7
+ onPacket: PacketHandler
8
+ onConnect?: (conn: Connection) => void
9
+ onClose?: (conn: Connection) => void
10
+ }
11
+
12
+ export interface TransportOptions {
13
+ port: number
14
+ defaultLayer: number
15
+ /**
16
+ * Trust an upstream proxy for the client address: parse the PROXY-protocol
17
+ * header on raw TCP, and trust `X-Forwarded-For` on WebSocket. Leave off
18
+ * (default) when clients connect directly — the headers are spoofable.
19
+ */
20
+ trustProxy?: boolean
21
+ /** Structured logger; the carrier derives a per-connection child from it. */
22
+ logger?: Logger
23
+ }
24
+
25
+ /**
26
+ * Feed a received byte chunk into a connection's framing and enqueue any
27
+ * complete packets. Shared by the WebSocket and raw-TCP carriers — both deliver
28
+ * bytes (WS as binary frames, TCP as a stream) to the same stateful framer.
29
+ */
30
+ export function pump(conn: Connection, chunk: Buffer, handlers: TransportHandlers): void {
31
+ let packets: Buffer[]
32
+ try {
33
+ packets = conn.framing.feed(chunk)
34
+ } catch (err) {
35
+ // Unframable bytes = a broken/hostile client; drop the connection. Expected
36
+ // enough to be a warn, not an error (no server fault).
37
+ conn.log.warn('framing.error', { err: String(err) })
38
+ conn.close()
39
+ return
40
+ }
41
+ if (packets.length && conn.log.isLevelEnabled('trace')) {
42
+ conn.log.trace('framing.packets', {
43
+ packets: packets.map(p => ({ bytes: p.length, head: p.subarray(0, 8).toString('hex') })),
44
+ })
45
+ }
46
+ for (const packet of packets) {
47
+ conn.enqueue(() => handlers.onPacket(packet, conn))
48
+ }
49
+ }
@@ -0,0 +1,102 @@
1
+ import { createServer, type AddressInfo, type Server, type Socket } from 'node:net'
2
+ import { noopLogger } from '@mt-tl/tl'
3
+ import { Connection } from './connection.js'
4
+ import { pump, type TransportHandlers, type TransportOptions } from './server-common.js'
5
+ import { parseProxyHeader } from './proxy-protocol.js'
6
+
7
+ /**
8
+ * Raw TCP carrier for MTProto, for legacy clients that connect over a plain
9
+ * socket rather than WebSocket. The byte stream is fed into the same framing as
10
+ * WS (the framer already reassembles packets across `data` events), so abridged
11
+ * / intermediate / full / obfuscated transports all work identically here.
12
+ */
13
+ export class MtprotoTcpServer {
14
+ private server?: Server
15
+ private lastId = 0
16
+
17
+ constructor(
18
+ private readonly options: TransportOptions,
19
+ private readonly handlers: TransportHandlers,
20
+ ) {}
21
+
22
+ listen(): Promise<void> {
23
+ return new Promise((resolve, reject) => {
24
+ this.server = createServer({ allowHalfOpen: false }, socket => this.onConnection(socket))
25
+ this.server.once('error', reject)
26
+ this.server.listen(this.options.port, () => resolve())
27
+ })
28
+ }
29
+
30
+ get port(): number {
31
+ const addr = this.server?.address()
32
+ return addr && typeof addr === 'object' ? (addr as AddressInfo).port : this.options.port
33
+ }
34
+
35
+ private onConnection(socket: Socket): void {
36
+ socket.setNoDelay(true)
37
+ const id = ++this.lastId
38
+
39
+ const log = (this.options.logger ?? noopLogger).child({ scope: 'tcp', conn: id })
40
+ log.info('conn.open', { remote: socket.remoteAddress })
41
+
42
+ const conn = new Connection(
43
+ id,
44
+ bytes => {
45
+ try {
46
+ socket.write(bytes)
47
+ } catch {
48
+ conn.close()
49
+ }
50
+ },
51
+ () => socket.destroy(),
52
+ socket.remoteAddress,
53
+ this.options.defaultLayer,
54
+ log,
55
+ )
56
+
57
+ this.handlers.onConnect?.(conn)
58
+ socket.on(
59
+ 'data',
60
+ this.options.trustProxy ? this.proxyAware(conn) : chunk => pump(conn, chunk, this.handlers),
61
+ )
62
+ socket.on('close', () => {
63
+ log.info('conn.close')
64
+ conn.closed = true
65
+ this.handlers.onClose?.(conn)
66
+ })
67
+ socket.on('error', err => {
68
+ log.warn('tcp.error', { err: String(err) })
69
+ conn.close()
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Data handler that strips a leading PROXY-protocol header (when present)
75
+ * before the byte stream reaches framing, recording the announced client IP
76
+ * on the connection. Once the header is consumed (or shown absent), all
77
+ * further bytes pass straight to `pump`.
78
+ */
79
+ private proxyAware(conn: Connection): (chunk: Buffer) => void {
80
+ let header: Buffer | null = Buffer.alloc(0)
81
+ return (chunk: Buffer) => {
82
+ if (header === null) return pump(conn, chunk, this.handlers)
83
+ header = header.length ? Buffer.concat([header, chunk]) : chunk
84
+ const res = parseProxyHeader(header)
85
+ if (res.status === 'incomplete') return
86
+ const buffered = header
87
+ header = null // done parsing; subsequent chunks bypass this path
88
+ if (res.status === 'done') {
89
+ if (res.sourceIp) conn.ctx.remoteAddress = res.sourceIp
90
+ const rest = buffered.subarray(res.consumed)
91
+ if (rest.length) pump(conn, rest, this.handlers)
92
+ } else {
93
+ // No PROXY header — the bytes are the MTProto stream itself.
94
+ pump(conn, buffered, this.handlers)
95
+ }
96
+ }
97
+ }
98
+
99
+ close(): void {
100
+ this.server?.close()
101
+ }
102
+ }
@@ -0,0 +1,94 @@
1
+ import { WebSocketServer, type WebSocket } from 'ws'
2
+ import type { AddressInfo } from 'node:net'
3
+ import type { IncomingMessage } from 'node:http'
4
+ import { noopLogger } from '@mt-tl/tl'
5
+ import { Connection } from './connection.js'
6
+ import { pump, type TransportHandlers, type TransportOptions } from './server-common.js'
7
+
8
+ export type { PacketHandler, TransportHandlers } from './server-common.js'
9
+
10
+ function toBuffer(data: Buffer | ArrayBuffer | Buffer[]): Buffer {
11
+ if (Buffer.isBuffer(data)) return data
12
+ if (Array.isArray(data)) return Buffer.concat(data)
13
+ return Buffer.from(data)
14
+ }
15
+
16
+ /**
17
+ * WebSocket carrier for MTProto. Each binary frame is fed into the connection's
18
+ * transport framing; complete packets are handed to `onPacket` in arrival order.
19
+ */
20
+ export class MtprotoWsServer {
21
+ private wss?: WebSocketServer
22
+ private lastId = 0
23
+
24
+ constructor(
25
+ private readonly options: TransportOptions,
26
+ private readonly handlers: TransportHandlers,
27
+ ) {}
28
+
29
+ listen(): Promise<void> {
30
+ return new Promise(resolve => {
31
+ this.wss = new WebSocketServer({ port: this.options.port, clientTracking: false })
32
+ this.wss.on('connection', (socket, req) => this.onConnection(socket, req))
33
+ this.wss.on('listening', () => resolve())
34
+ })
35
+ }
36
+
37
+ /** Bound TCP port (useful when listening on port 0 in tests). */
38
+ get port(): number {
39
+ const addr = this.wss?.address()
40
+ return addr && typeof addr === 'object' ? (addr as AddressInfo).port : this.options.port
41
+ }
42
+
43
+ private onConnection(socket: WebSocket, req: IncomingMessage): void {
44
+ const id = ++this.lastId
45
+ // Only trust the (spoofable) X-Forwarded-For when an upstream proxy is declared.
46
+ const forwarded = this.options.trustProxy
47
+ ? (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim()
48
+ : undefined
49
+ const remote = forwarded ?? req.socket.remoteAddress ?? undefined
50
+
51
+ const log = (this.options.logger ?? noopLogger).child({ scope: 'ws', conn: id })
52
+ log.info('conn.open', { remote })
53
+ if (log.isLevelEnabled('trace')) {
54
+ log.trace('ws.connect', {
55
+ host: req.headers.host,
56
+ xff: req.headers['x-forwarded-for'],
57
+ proto: req.headers['sec-websocket-protocol'],
58
+ ua: req.headers['user-agent'],
59
+ })
60
+ }
61
+
62
+ const conn = new Connection(
63
+ id,
64
+ bytes => socket.send(bytes, { binary: true }),
65
+ () => socket.close(),
66
+ remote,
67
+ this.options.defaultLayer,
68
+ log,
69
+ )
70
+
71
+ this.handlers.onConnect?.(conn)
72
+ socket.on('message', (data: Buffer | ArrayBuffer | Buffer[]) => {
73
+ const buf = toBuffer(data)
74
+ if (log.isLevelEnabled('trace'))
75
+ log.trace('ws.recv', { bytes: buf.length, head: buf.subarray(0, 16).toString('hex') })
76
+ pump(conn, buf, this.handlers)
77
+ })
78
+ socket.on('close', (code, reason) => {
79
+ log.info('conn.close', { code })
80
+ if (reason?.length && log.isLevelEnabled('trace'))
81
+ log.trace('ws.close', { reason: reason.toString() })
82
+ conn.closed = true
83
+ this.handlers.onClose?.(conn)
84
+ })
85
+ socket.on('error', err => {
86
+ log.warn('ws.error', { err: String(err) })
87
+ conn.close()
88
+ })
89
+ }
90
+
91
+ close(): void {
92
+ this.wss?.close()
93
+ }
94
+ }