auto_recon_by_ip and minor patch

This commit is contained in:
Rolands 2024-07-11 14:33:32 +03:00
parent 2eb7b129e7
commit 0ca0dad395
4 changed files with 102 additions and 64 deletions

View file

@ -16,6 +16,7 @@ type QueryObjectSQL = { sql?: string, endpoint?: string, params?: object | null
type QueryObject = QueryObjectSQL & { onUpdate: SubscribeCallbackObject };
type QueryPromise = { res: Function, prom:Promise<any> | null, start_buff: number, payload_size?:number };
export type ProgressOnUpdate = (percentage: number) => void;
type ReconData = { id?: id, result: { old_client_id: ClientID, auth: boolean }, success: Bit };
type PropUpdateCallback = ((new_val: PropValue, diff?: diff_lib.rdiffResult[]) => void) | null;
export type ClientProp = { val: PropValue | undefined, subs: { [id: id]: PropUpdateCallback } };
@ -131,7 +132,7 @@ export class SocioClient extends LogHandler {
}
case ClientMessageKind.AUTH:{
this.#FindID(kind, data?.id)
if (data?.result?.success as Bit !== 1)
if (data?.result as Bit !== 1)
this.HandleInfo(`AUTH returned FALSE, which means websocket has not authenticated.`);
this.#authenticated = data?.result as Bit === 1;
@ -189,10 +190,14 @@ export class SocioClient extends LogHandler {
break;
}
case ClientMessageKind.RECON:{
this.#FindID(kind, data?.id);
//@ts-expect-error
this.#queries.get(data.id)(data);
this.#queries.delete(data.id); //clear memory
if(data?.id){
this.#FindID(kind, data.id);
(this.#queries.get(data.id) as QueryPromise).res(data);
this.#queries.delete(data.id); //clear memory
}
// sent without id, so the recon was automatic on the server-side, not requested by this client
else
this.#Reconnect(data as unknown as ReconData);
break;
}
case ClientMessageKind.RECV_FILES:{
@ -464,15 +469,12 @@ export class SocioClient extends LogHandler {
}
#HandleBasicPromiseMessage(kind: ClientMessageKind, data:ClientMessageDataObj){
this.#FindID(kind, data?.id);
const q = this.#queries.get(data.id);
// @ts-expect-error
if(q?.res)
const q = this.#queries.get(data.id) as QueryObject | QueryPromise;
if (q.hasOwnProperty('res'))
(q as QueryPromise).res(data?.result as any);
// @ts-expect-error
else if (q?.onUpdate)
if ((q as QueryObject)?.onUpdate?.success)
// @ts-expect-error
(q as QueryObject).onUpdate.success(data?.result as Bit);
else if (q.hasOwnProperty('onUpdate') && (q as QueryObject).onUpdate?.success)
//@ts-expect-error
q.onUpdate.success(data?.result as Bit);
this.#queries.delete(data.id); //clear memory
}
@ -510,20 +512,19 @@ export class SocioClient extends LogHandler {
//ask the server for a reconnection to an old session via our one-time token
this.Send(CoreMessageKind.RECON, { id, data: { type: 'POST', token } });
const res = await prom;
//@ts-ignore
if (res?.success){
//@ts-ignore
this.#authenticated = res?.result?.auth;
//@ts-ignore
this.done(`${this.config.name} reconnected successfully. ${res?.result?.old_client_id} -> ${this.#client_id} (old client ID -> new/current client ID)`)
}
else
this.HandleError(new E('Failed to reconnect', res));
const res = await (prom as unknown as Promise<ReconData>);
this.#Reconnect(res); //sets the trusted values from the server, like auth bool
}
}
//sets the trusted values from the server, like auth bool
#Reconnect(data:ReconData){
if (data?.success) {
this.#authenticated = data?.result.auth;
this.done(`${this.config.name} reconnected successfully. ${data.result.old_client_id} -> ${this.#client_id} (old client ID -> new/current client ID)`, data);
}
else
this.HandleError(new E('Failed to reconnect', data));
}
// for dev debug, if u want
LogMaps(){

View file

@ -27,7 +27,7 @@ export type QueryFunction = (client: SocioSession, id: id, sql: string, params?:
type SessionsDefaults = { timeouts: boolean, timeouts_check_interval_ms?: number, session_delete_delay_ms?: number, recon_ttl_ms?: number } & SessionOpts;
type DecryptOptions = { decrypt_sql: boolean, decrypt_prop: boolean, decrypt_endpoint: boolean };
type DBOpts = { Query: QueryFunction, Arbiter?: (initiator: { client: SocioSession, sql: string, params: any }, current: { client: SocioSession, hook: SubObj }) => boolean | Promise<boolean>};
type SocioServerOptions = { db: DBOpts, socio_security?: SocioSecurity | null, decrypt_opts?: DecryptOptions, hard_crash?: boolean, session_defaults?: SessionsDefaults, prop_upd_diff?: boolean, [key:string]:any } & LoggingOpts;
type SocioServerOptions = { db: DBOpts, socio_security?: SocioSecurity | null, decrypt_opts?: DecryptOptions, hard_crash?: boolean, session_defaults?: SessionsDefaults, prop_upd_diff?: boolean, auto_recon_by_ip?:boolean, [key:string]:any } & LoggingOpts;
type AdminMessageDataObj = {function:string, args?:any[], secure_key:string};
type BasicClientResponse = { id: id | string, data?: any, result?: Bit | string | {success: Bit | string} | object, [key: string]: any };
@ -35,7 +35,7 @@ type BasicClientResponse = { id: id | string, data?: any, result?: Bit | string
//whereas public variables are free for you to alter freely at any time during runtime.
export class SocioServer extends LogHandler {
// private:
//---private:
#wss: WebSocketServer;
#sessions: Map<ClientID, SocioSession> = new Map(); //Maps are quite more performant than objects. And their keys dont overlap with Object prototype.
@ -63,17 +63,18 @@ export class SocioServer extends LogHandler {
//global flag to send prop obj diffs using the diff lib instead of the full object every time.
#prop_upd_diff = false;
//public:
//---public:
db!: DBOpts;
session_defaults: SessionsDefaults = { timeouts: false, timeouts_check_interval_ms: 1000 * 60, session_timeout_ttl_ms: Infinity, session_delete_delay_ms: 1000 * 5, recon_ttl_ms: 1000 * 60 * 60 };
prop_reg_timeout_ms!: number;
auto_recon_by_ip:boolean = false;
constructor(opts: ServerOptions | undefined = {}, { db, socio_security = null, logging = { verbose: false, hard_crash: false }, decrypt_opts = { decrypt_sql: true, decrypt_prop: false, decrypt_endpoint:false}, session_defaults = undefined, prop_upd_diff=false, prop_reg_timeout_ms=1000*10 }: SocioServerOptions){
constructor(opts: ServerOptions | undefined = {}, { db, socio_security = null, logging = { verbose: false, hard_crash: false }, decrypt_opts = { decrypt_sql: true, decrypt_prop: false, decrypt_endpoint: false }, session_defaults = undefined, prop_upd_diff = false, prop_reg_timeout_ms = 1000 * 10, auto_recon_by_ip = false }: SocioServerOptions){
super({ ...logging, prefix:'SocioServer'});
//verbose - print stuff to the console using my lib. Doesnt affect the log handlers
//hard_crash will just crash the class instance and propogate (throw) the error encountered without logging it anywhere - up to you to handle.
//both are public and settable at runtime
//private:
this.#wss = new WebSocketServer({ ...opts, clientTracking: true }); //take a look at the WebSocketServer docs - the opts can have a server param, that can be your http server
this.#secure = { socio_security, ...decrypt_opts };
@ -84,6 +85,7 @@ export class SocioServer extends LogHandler {
this.db = db;
this.session_defaults = Object.assign(this.session_defaults, session_defaults);
this.prop_reg_timeout_ms = prop_reg_timeout_ms;
this.auto_recon_by_ip = auto_recon_by_ip;
this.#wss.on('connection', this.#Connect.bind(this)); //https://thenewstack.io/mastering-javascript-callbacks-bind-apply-call/ have to bind 'this' to the function, otherwise it will use the .on()'s 'this', so that this.[prop] are not undefined
this.#wss.on('close', (...stuff) => { this.HandleInfo('WebSocketServer close event', ...stuff) });
@ -95,8 +97,8 @@ export class SocioServer extends LogHandler {
const addr: AddressInfo = this.#wss.address() as AddressInfo;
if (this.verbose) this.done(`Created SocioServer on `, addr);
// if (addr.family == 'ws')
// this.HandleInfo('WARNING! Your server is using an unsecure WebSocket protocol, setup wss:// instead, when you can!');
if (addr.family == 'ws')
this.HandleInfo('WARNING! Your server is using an unsecure WebSocket protocol, setup wss:// instead, when you can!');
}
async #Connect(conn: WebSocket, request: IncomingMessage){
@ -118,10 +120,6 @@ export class SocioServer extends LogHandler {
if (this.#lifecycle_hooks.con)
await this.#lifecycle_hooks.con(client, request); //u can get the client_id and client_ip off the client object
//notify the client of their ID
client.Send(ClientMessageKind.CON, client_id);
this.HandleInfo('CON', client_id); //, this.#wss.clients
//set this client websockets event handlers
conn.on('message', (req: Buffer | ArrayBuffer | Buffer[], isBinary: Boolean) => {
if (this.#sessions.has(client_id))//@ts-expect-error
@ -130,6 +128,24 @@ export class SocioServer extends LogHandler {
});
conn.on('close', (code:number, reason:Buffer) => { this.#SocketClosed.bind(this)(client, {code, reason:reason.toString('utf8')}) });
conn.on('error', (error: Error) => { this.#SocketClosed.bind(this)(client, error) }); //https://github.com/websockets/ws/blob/master/doc/ws.md#event-error-1
// socio can recognize that the IP matches an existing session, so it can reconnect to it, keeping the old sessions data
if(this.auto_recon_by_ip){
// find an IP matching session
for (const [id, ses] of this.#sessions.entries()){
if(id !== client_id && ses.ipAddr === client_ip){
//recon procedure
const old_client = this.#sessions.get(id) as SocioSession;
this.ReconnectClientSession(client, old_client);
this.HandleInfo(`AUTO IP RECON | old id: ${id} -> new id: ${client.id} | IP: ${client_ip}`);
break;
}
}
}
//notify the client of their ID
client.Send(ClientMessageKind.CON, client_id);
this.HandleInfo('CON', { id: client_id, ip: client_ip }); //, this.#wss.clients
} catch (e: err) { this.HandleError(e); }
}
@ -251,13 +267,13 @@ export class SocioServer extends LogHandler {
}
case CoreMessageKind.AUTH: {//client requests to authenticate itself with the server
if (client.authenticated) //check if already has auth
client.Send(ClientMessageKind.AUTH, { id: data.id, result: {success: 1} });
client.Send(ClientMessageKind.AUTH, { id: data.id, result: 1 });
else if (this.#lifecycle_hooks.auth) {
const res = await client.Authenticate(this.#lifecycle_hooks.auth, data.params) //bcs its a private class field, give this function the hook to call and params to it. It will set its field and give back the result. NOTE this is safer than adding a setter to a private field
client.Send(ClientMessageKind.AUTH, { id: data.id, result: res == true ? 1 : 0 }) //authenticated can be any truthy or falsy value, but the client will only receive a boolean, so its safe to set this to like an ID or token or smth for your own use
const res = await client.Authenticate(this.#lifecycle_hooks.auth, data.params); //bcs its a private class field, give this function the hook to call and params to it. It will set its field and give back the result. NOTE this is safer than adding a setter to a private field
client.Send(ClientMessageKind.AUTH, { id: data.id, result: res === true ? 1 : 0 }); //authenticated can be any truthy or falsy value, but the client will only receive a boolean, so its safe to set this to like an ID or token or smth for your own use
} else {
this.HandleError('AUTH function hook not registered, so client not authenticated. [#no-auth-func]')
client.Send(ClientMessageKind.AUTH, { id: data.id, result: 0 })
this.HandleError('AUTH function hook not registered, so client not authenticated. [#no-auth-func]');
client.Send(ClientMessageKind.AUTH, { id: data.id, result: 0 });
}
break;
}
@ -461,22 +477,8 @@ export class SocioServer extends LogHandler {
//recon procedure
const old_client = this.#sessions.get(old_c_id) as SocioSession;
old_client.Restore();//stop the old session deletion, since a reconnect was actually attempted
client.CopySessionFrom(old_client);
//clear the subscriptions on the sockets, since the new instance will define new ones on the new page. Also to avoid ID conflicts
this.#ClearClientSessionSubs(old_c_id);
this.#ClearClientSessionSubs(client.id);
//delete old session for good
old_client.Destroy(() => {
this.#ClearClientSessionSubs(old_c_id);
this.#sessions.delete(old_c_id);
}, this.session_defaults.session_delete_delay_ms as number);
//notify the client
client.Send(ClientMessageKind.RECON, { id: data.id, result: { old_client_id: old_c_id, auth: client.authenticated }, success: 1 });
this.HandleInfo(`RECON ${old_c_id} -> ${client.id} (old client ID -> new/current client ID)`);
this.ReconnectClientSession(client, old_client, data.id as id);
this.HandleInfo(`RECON | old id: ${old_c_id} -> new id: ${client.id}`);
}
break;
}
@ -788,6 +790,27 @@ export class SocioServer extends LogHandler {
}
}
ReconnectClientSession(new_session: SocioSession, old_session: SocioSession, client_notify_msg_id?:id){
const new_id = new_session.id, old_id = old_session.id;
old_session.Restore();//stop the old session deletion, since a reconnect was actually attempted
new_session.CopySessionFrom(old_session);
//clear the subscriptions on the sockets, since the new instance will define new ones on the new page. Also to avoid ID conflicts
this.#ClearClientSessionSubs(old_id);
this.#ClearClientSessionSubs(new_id);
//delete old session for good
old_session.Destroy(() => {
this.#ClearClientSessionSubs(old_id);
this.#sessions.delete(old_id);
}, this.session_defaults.session_delete_delay_ms as number);
//notify the client
const data = { result: { old_client_id: old_id, auth: new_session.authenticated }, success: 1 };
if (client_notify_msg_id) data['id'] = client_notify_msg_id;
new_session.Send(ClientMessageKind.RECON, data);
}
get session_ids(){return this.#sessions.keys();}
get server_info() { return this.#wss.address(); }
get raw_websocket_server() { return this.#wss; }

View file

@ -7,7 +7,7 @@ import { ClientMessageKind } from './core-client.js';
//types
import type { WebSocket } from 'ws'; //https://github.com/websockets/ws https://github.com/websockets/ws/blob/master/doc/ws.md
import type { id, Bit, LoggingOpts, SessionOpts } from './types.js';
import type { id, Bit, LoggingOpts, SessionOpts, Auth_Hook } from './types.js';
import type { RateLimit } from './ratelimit.js';
export type SubObj = {
@ -90,9 +90,9 @@ export class SocioSession extends LogHandler {
}
get authenticated() { return this.#authenticated }
async Authenticate(auth_func:Function, params:object|null=null) { //auth func can return any truthy or falsy value, the client will only receive a boolean, so its safe to set it to some credential or id or smth, as this would be accessible and useful to you when checking the session access to tables
const auth:boolean = await auth_func(this, params);
this.#authenticated = auth == true;
async Authenticate(auth_func: Auth_Hook, params:object|null=null) { //auth func can return any truthy or falsy value, the client will only receive a boolean, so its safe to set it to some credential or id or smth, as this would be accessible and useful to you when checking the session access to tables
const auth = await auth_func(this, params);
this.#authenticated = auth === true;
return auth;
}