@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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/auth/handshake.d.ts +35 -0
- package/dist/auth/handshake.d.ts.map +1 -0
- package/dist/auth/handshake.js +208 -0
- package/dist/auth/handshake.js.map +1 -0
- package/dist/auth/nonce-store.d.ts +22 -0
- package/dist/auth/nonce-store.d.ts.map +1 -0
- package/dist/auth/nonce-store.js +23 -0
- package/dist/auth/nonce-store.js.map +1 -0
- package/dist/bootstrap.d.ts +36 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +82 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/config.d.ts +103 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/core/context.d.ts +57 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js +27 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/errors.d.ts +33 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +47 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +5 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/rpc.d.ts +83 -0
- package/dist/core/rpc.d.ts.map +1 -0
- package/dist/core/rpc.js +102 -0
- package/dist/core/rpc.js.map +1 -0
- package/dist/core/updates.d.ts +56 -0
- package/dist/core/updates.d.ts.map +1 -0
- package/dist/core/updates.js +34 -0
- package/dist/core/updates.js.map +1 -0
- package/dist/create-server.d.ts +89 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +109 -0
- package/dist/create-server.js.map +1 -0
- package/dist/crypto/aes-ige.d.ts +3 -0
- package/dist/crypto/aes-ige.d.ts.map +1 -0
- package/dist/crypto/aes-ige.js +55 -0
- package/dist/crypto/aes-ige.js.map +1 -0
- package/dist/crypto/dh.d.ts +21 -0
- package/dist/crypto/dh.d.ts.map +1 -0
- package/dist/crypto/dh.js +99 -0
- package/dist/crypto/dh.js.map +1 -0
- package/dist/crypto/hashes.d.ts +6 -0
- package/dist/crypto/hashes.d.ts.map +1 -0
- package/dist/crypto/hashes.js +14 -0
- package/dist/crypto/hashes.js.map +1 -0
- package/dist/crypto/msg-key.d.ts +15 -0
- package/dist/crypto/msg-key.d.ts.map +1 -0
- package/dist/crypto/msg-key.js +24 -0
- package/dist/crypto/msg-key.js.map +1 -0
- package/dist/crypto/rsa.d.ts +27 -0
- package/dist/crypto/rsa.d.ts.map +1 -0
- package/dist/crypto/rsa.js +50 -0
- package/dist/crypto/rsa.js.map +1 -0
- package/dist/dispatch/dispatcher.d.ts +72 -0
- package/dist/dispatch/dispatcher.d.ts.map +1 -0
- package/dist/dispatch/dispatcher.js +503 -0
- package/dist/dispatch/dispatcher.js.map +1 -0
- package/dist/dispatch/forwarders/in-process.d.ts +12 -0
- package/dist/dispatch/forwarders/in-process.d.ts.map +1 -0
- package/dist/dispatch/forwarders/in-process.js +15 -0
- package/dist/dispatch/forwarders/in-process.js.map +1 -0
- package/dist/dispatch/forwarders/print.d.ts +14 -0
- package/dist/dispatch/forwarders/print.d.ts.map +1 -0
- package/dist/dispatch/forwarders/print.js +23 -0
- package/dist/dispatch/forwarders/print.js.map +1 -0
- package/dist/dispatch/rpc-forwarder.d.ts +14 -0
- package/dist/dispatch/rpc-forwarder.d.ts.map +1 -0
- package/dist/dispatch/rpc-forwarder.js +2 -0
- package/dist/dispatch/rpc-forwarder.js.map +1 -0
- package/dist/dispatch/types.d.ts +32 -0
- package/dist/dispatch/types.d.ts.map +1 -0
- package/dist/dispatch/types.js +23 -0
- package/dist/dispatch/types.js.map +1 -0
- package/dist/gateway.d.ts +57 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +150 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +12 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +13 -0
- package/dist/lib.js.map +1 -0
- package/dist/server/message-pipeline.d.ts +57 -0
- package/dist/server/message-pipeline.d.ts.map +1 -0
- package/dist/server/message-pipeline.js +199 -0
- package/dist/server/message-pipeline.js.map +1 -0
- package/dist/session/inbound-tracker.d.ts +118 -0
- package/dist/session/inbound-tracker.d.ts.map +1 -0
- package/dist/session/inbound-tracker.js +170 -0
- package/dist/session/inbound-tracker.js.map +1 -0
- package/dist/session/message-id.d.ts +19 -0
- package/dist/session/message-id.d.ts.map +1 -0
- package/dist/session/message-id.js +30 -0
- package/dist/session/message-id.js.map +1 -0
- package/dist/session/salts.d.ts +51 -0
- package/dist/session/salts.d.ts.map +1 -0
- package/dist/session/salts.js +132 -0
- package/dist/session/salts.js.map +1 -0
- package/dist/session/session-manager.d.ts +18 -0
- package/dist/session/session-manager.d.ts.map +1 -0
- package/dist/session/session-manager.js +43 -0
- package/dist/session/session-manager.js.map +1 -0
- package/dist/storage/index.d.ts +14 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/memory.d.ts +3 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +91 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/mongo.d.ts +3 -0
- package/dist/storage/mongo.d.ts.map +1 -0
- package/dist/storage/mongo.js +175 -0
- package/dist/storage/mongo.js.map +1 -0
- package/dist/storage/types.d.ts +85 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +3 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/testkit.d.ts +11 -0
- package/dist/testkit.d.ts.map +1 -0
- package/dist/testkit.js +17 -0
- package/dist/testkit.js.map +1 -0
- package/dist/tl/codec.d.ts +37 -0
- package/dist/tl/codec.d.ts.map +1 -0
- package/dist/tl/codec.js +297 -0
- package/dist/tl/codec.js.map +1 -0
- package/dist/tl/layered-registry.d.ts +29 -0
- package/dist/tl/layered-registry.d.ts.map +1 -0
- package/dist/tl/layered-registry.js +118 -0
- package/dist/tl/layered-registry.js.map +1 -0
- package/dist/tl/protocol.d.ts +126 -0
- package/dist/tl/protocol.d.ts.map +1 -0
- package/dist/tl/protocol.js +22 -0
- package/dist/tl/protocol.js.map +1 -0
- package/dist/tl/reader.d.ts +30 -0
- package/dist/tl/reader.d.ts.map +1 -0
- package/dist/tl/reader.js +87 -0
- package/dist/tl/reader.js.map +1 -0
- package/dist/tl/registry.d.ts +39 -0
- package/dist/tl/registry.d.ts.map +1 -0
- package/dist/tl/registry.js +52 -0
- package/dist/tl/registry.js.map +1 -0
- package/dist/tl/writer.d.ts +24 -0
- package/dist/tl/writer.d.ts.map +1 -0
- package/dist/tl/writer.js +95 -0
- package/dist/tl/writer.js.map +1 -0
- package/dist/transport/connection-registry.d.ts +31 -0
- package/dist/transport/connection-registry.d.ts.map +1 -0
- package/dist/transport/connection-registry.js +84 -0
- package/dist/transport/connection-registry.js.map +1 -0
- package/dist/transport/connection.d.ts +62 -0
- package/dist/transport/connection.d.ts.map +1 -0
- package/dist/transport/connection.js +77 -0
- package/dist/transport/connection.js.map +1 -0
- package/dist/transport/framing.d.ts +39 -0
- package/dist/transport/framing.d.ts.map +1 -0
- package/dist/transport/framing.js +212 -0
- package/dist/transport/framing.js.map +1 -0
- package/dist/transport/proxy-protocol.d.ts +23 -0
- package/dist/transport/proxy-protocol.d.ts.map +1 -0
- package/dist/transport/proxy-protocol.js +108 -0
- package/dist/transport/proxy-protocol.js.map +1 -0
- package/dist/transport/server-common.d.ts +27 -0
- package/dist/transport/server-common.d.ts.map +1 -0
- package/dist/transport/server-common.js +27 -0
- package/dist/transport/server-common.js.map +1 -0
- package/dist/transport/tcp-server.d.ts +26 -0
- package/dist/transport/tcp-server.d.ts.map +1 -0
- package/dist/transport/tcp-server.js +91 -0
- package/dist/transport/tcp-server.js.map +1 -0
- package/dist/transport/ws-server.d.ts +19 -0
- package/dist/transport/ws-server.d.ts.map +1 -0
- package/dist/transport/ws-server.js +78 -0
- package/dist/transport/ws-server.js.map +1 -0
- package/dist/update-publisher.d.ts +34 -0
- package/dist/update-publisher.d.ts.map +1 -0
- package/dist/update-publisher.js +29 -0
- package/dist/update-publisher.js.map +1 -0
- package/dist/updates/mongo-update-log.d.ts +13 -0
- package/dist/updates/mongo-update-log.d.ts.map +1 -0
- package/dist/updates/mongo-update-log.js +39 -0
- package/dist/updates/mongo-update-log.js.map +1 -0
- package/dist/updates/presence-binder.d.ts +29 -0
- package/dist/updates/presence-binder.d.ts.map +1 -0
- package/dist/updates/presence-binder.js +36 -0
- package/dist/updates/presence-binder.js.map +1 -0
- package/dist/updates/presence.d.ts +31 -0
- package/dist/updates/presence.d.ts.map +1 -0
- package/dist/updates/presence.js +44 -0
- package/dist/updates/presence.js.map +1 -0
- package/dist/updates/push.d.ts +25 -0
- package/dist/updates/push.d.ts.map +1 -0
- package/dist/updates/push.js +72 -0
- package/dist/updates/push.js.map +1 -0
- package/dist/updates/redis-bus.d.ts +45 -0
- package/dist/updates/redis-bus.d.ts.map +1 -0
- package/dist/updates/redis-bus.js +59 -0
- package/dist/updates/redis-bus.js.map +1 -0
- package/dist/updates/redis-presence.d.ts +43 -0
- package/dist/updates/redis-presence.d.ts.map +1 -0
- package/dist/updates/redis-presence.js +65 -0
- package/dist/updates/redis-presence.js.map +1 -0
- package/dist/updates/render.d.ts +16 -0
- package/dist/updates/render.d.ts.map +1 -0
- package/dist/updates/render.js +46 -0
- package/dist/updates/render.js.map +1 -0
- package/dist/updates/router.d.ts +27 -0
- package/dist/updates/router.d.ts.map +1 -0
- package/dist/updates/router.js +36 -0
- package/dist/updates/router.js.map +1 -0
- package/dist/updates/types.d.ts +23 -0
- package/dist/updates/types.d.ts.map +1 -0
- package/dist/updates/types.js +2 -0
- package/dist/updates/types.js.map +1 -0
- package/dist/updates/update-bus.d.ts +29 -0
- package/dist/updates/update-bus.d.ts.map +1 -0
- package/dist/updates/update-bus.js +24 -0
- package/dist/updates/update-bus.js.map +1 -0
- package/dist/util/bytes.d.ts +12 -0
- package/dist/util/bytes.d.ts.map +1 -0
- package/dist/util/bytes.js +46 -0
- package/dist/util/bytes.js.map +1 -0
- package/package.json +84 -0
- package/src/auth/handshake.ts +262 -0
- package/src/auth/nonce-store.ts +39 -0
- package/src/bootstrap.ts +114 -0
- package/src/config.ts +103 -0
- package/src/core/context.ts +94 -0
- package/src/core/errors.ts +52 -0
- package/src/core/index.ts +4 -0
- package/src/core/rpc.ts +165 -0
- package/src/core/updates.ts +69 -0
- package/src/create-server.ts +181 -0
- package/src/crypto/aes-ige.ts +57 -0
- package/src/crypto/dh.ts +101 -0
- package/src/crypto/hashes.ts +17 -0
- package/src/crypto/msg-key.ts +29 -0
- package/src/crypto/rsa.ts +70 -0
- package/src/dispatch/dispatcher.ts +586 -0
- package/src/dispatch/forwarders/in-process.ts +14 -0
- package/src/dispatch/forwarders/print.ts +22 -0
- package/src/dispatch/rpc-forwarder.ts +15 -0
- package/src/dispatch/types.ts +60 -0
- package/src/gateway.ts +214 -0
- package/src/index.ts +53 -0
- package/src/lib.ts +24 -0
- package/src/server/message-pipeline.ts +256 -0
- package/src/session/inbound-tracker.ts +221 -0
- package/src/session/message-id.ts +43 -0
- package/src/session/salts.ts +162 -0
- package/src/session/session-manager.ts +66 -0
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +101 -0
- package/src/storage/mongo.ts +215 -0
- package/src/storage/types.ts +92 -0
- package/src/testkit.ts +19 -0
- package/src/tl/codec.ts +292 -0
- package/src/tl/layered-registry.ts +132 -0
- package/src/tl/protocol.ts +146 -0
- package/src/tl/reader.ts +99 -0
- package/src/tl/registry.ts +78 -0
- package/src/tl/writer.ts +104 -0
- package/src/transport/connection-registry.ts +91 -0
- package/src/transport/connection.ts +113 -0
- package/src/transport/framing.ts +223 -0
- package/src/transport/proxy-protocol.ts +109 -0
- package/src/transport/server-common.ts +49 -0
- package/src/transport/tcp-server.ts +102 -0
- package/src/transport/ws-server.ts +94 -0
- package/src/update-publisher.ts +47 -0
- package/src/updates/mongo-update-log.ts +49 -0
- package/src/updates/presence-binder.ts +51 -0
- package/src/updates/presence.ts +61 -0
- package/src/updates/push.ts +90 -0
- package/src/updates/redis-bus.ts +86 -0
- package/src/updates/redis-presence.ts +87 -0
- package/src/updates/render.ts +53 -0
- package/src/updates/router.ts +52 -0
- package/src/updates/types.ts +24 -0
- package/src/updates/update-bus.ts +49 -0
- 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
|
+
}
|