main
ts 122 lines 4.38 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { Socket } from 'net'
4 import events from './events'
5 import { Context } from 'koa'
6 import { ip2country } from './geo'
7 import _ from 'lodash'
8
9 export class Connection {
10 readonly started = new Date()
11 sent = 0 // socket-scoped, not request-scoped
12 got = 0
13 outSpeedKb?: number
14 inSpeedKb?: number
15 ctx?: Context // this is set externally, during koa middleware, using updateConnectionForCtx, but only for regular requests; some connections may never have a ctx
16 country?: string
17 private _cachedIp?: string
18 [rest:symbol]: any // let other modules add extra data, but using symbols to avoid name collision
19
20 // the sockets we collect are the plain ones, as soon as the tcp layer is connected
21 constructor(public readonly socket: Socket) {
22 all.push(this)
23 socket.on('close', () => {
24 all.splice(all.indexOf(this), 1)
25 events.emit('connectionClosed', this)
26 })
27 events.emit('connection', this)
28 }
29
30 get ip() { // prioritize ctx.ip as it supports proxies, but fallback for when ctx is not yet available
31 if (this._cachedIp && this.ctx && this._cachedIp !== this.ctx.ip) {
32 events.emit('connectionNewIp', this, this._cachedIp, this.ctx.ip)
33 this._cachedIp = undefined
34 }
35 return this.ctx?.ip || (this._cachedIp ??= normalizeIp(this.socket.remoteAddress||''))
36 }
37
38 get secure() {
39 return (this.socket as any).server.cert > ''
40 }
41 }
42
43 export function normalizeIp(ip: string) {
44 return ip.replace(/^::ffff:/,'') // simplify ipv6-mapped addresses
45 }
46
47 const all: Connection[] = []
48
49 export function newConnection(socket: Socket) {
50 const ip = normalizeIp(socket.remoteAddress || '')
51 const res = events.emit('newSocket', { socket, ip })
52 const msg = res?.isDefaultPrevented() ? 'plugin (newSocket)' : res?.find(_.isString)
53 if (!msg)
54 return new Connection(socket)
55 disconnect(socket, msg)
56 }
57
58 export function getConnections(): Readonly<typeof all> {
59 return all
60 }
61
62 export function socket2connection(socket: Socket) {
63 return all.find(x => // socket exposed by Koa is TLSSocket which encapsulates simple Socket, and I've found no way to access it for simple comparison
64 x.socket.remotePort === socket.remotePort // but we can still match them because IP:PORT is key
65 && x.socket.remoteAddress === socket.remoteAddress )
66 }
67
68 export function getConnection(ctx: Context) {
69 return ctx.state.connection
70 }
71
72 export function updateConnectionForCtx(ctx: Context) {
73 const conn = getConnection(ctx)
74 if (conn)
75 updateConnection(conn, { ctx })
76 return conn
77 }
78
79 export function updateConnection(conn: Connection, change: Partial<Connection>, changeState?: true | Partial<Context['state']>) {
80 const { ctx } = conn
81 if (changeState && ctx) {
82 Object.assign(ctx.state, changeState)
83 Object.assign(change, { ctx })
84 }
85 Object.assign(conn, change)
86 events.emit('connectionUpdated', conn, change)
87 }
88
89 export const disconnectionsLog: { ts: Date, ip: string, country?: string, msg?: string }[] = []
90
91 export function disconnect(what: Context | Socket | Connection, logMessage='') {
92 if ('socket' in what)
93 what = what.socket
94 const ip = normalizeIp(what.remoteAddress || '')
95 if (logMessage)
96 console.debug("Disconnection:", logMessage, ip)
97 ip2country(ip).then(res => {
98 const rec = { ip, country: res || undefined, ts: new Date, msg: logMessage || undefined }
99 disconnectionsLog.push(rec)
100 if (disconnectionsLog.length > 1000)
101 disconnectionsLog.shift()
102 events.emit('disconnection', rec)
103 })
104 return what.destroy()
105 }
106
107
108 declare module "koa" {
109 interface BaseContext {
110 isAborted(): boolean
111 disconnect(logMessage?: string): unknown
112 }
113 }
114 events.once('app', app => { // can't simply import 'app', as it's not defined at this point (this file is required by 'plugins.ts' which is required by 'index.ts')
115 app.context.isAborted = function() {
116 return this.res.destroyed || this.req.aborted // investigate: "aborted" is deprecated, but "destroyed" will cause failure of some tests
117 || this.socket.destroyed
118 }
119 app.context.disconnect = function(logMessage='') {
120 return disconnect(this as Context, logMessage)
121 }
122 })