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

@ -72,7 +72,8 @@ socserv.RegisterLifecycleHookHandler("con", (client:SocioSession, req:IncomingMe
}); });
//all the hooks have their types in "socio/dist/types", so that you can see the hook param type inference in your IDE: //all the hooks have their types in "socio/dist/types", so that you can see the hook param type inference in your IDE:
const handle_admin_hook: Admin_Hook = (client, ...) => {...} const handle_auth_hook: Auth_Hook = (client, ...) => {...}
socserv.RegisterLifecycleHookHandler("auth", handle_auth_hook);
``` ```
#### Server and Client Hook definitions #### Server and Client Hook definitions
@ -97,7 +98,7 @@ import type { SocioSession } from 'socio/dist/core-session.js'
//keep track of which SocioSession client_id's have which of your database user_id's. //keep track of which SocioSession client_id's have which of your database user_id's.
const auth_clients:{[client_id:string]: number} = {}; const auth_clients:{[client_id:string]: number} = {};
socserv.RegisterLifecycleHookHandler("auth", (client:SocioSession, params: object | null | Array<any>) => { socserv.RegisterLifecycleHookHandler("auth", (client:SocioSession, params: object | null | Array<any>) => {
const user_id = DB.get(params);//...do some DB stuff to get the user_id from params, that may contain like username and password const user_id = DB.get(params);//...do some DB stuff to get the user_id from params, that may contain like username and password. This data will be encrypted by lower OSI layers, if using WSS:// (secure sockets). However, its still a good practice, that DB passwords should not be sent in plain-text.
auth_clients[client.id] = user_id; auth_clients[client.id] = user_id;
return true; return true;
}) })
@ -625,7 +626,20 @@ const sc = new SocioClient(`ws://localhost:3000`, {
``` ```
Note that the ``name`` must be set on the old and new instance and they must be identical, so that socio knows which 2 sessions are attempting to reconnect. Note that the ``name`` must be set on the old and new instance and they must be identical, so that socio knows which 2 sessions are attempting to reconnect.
After the reconnection attempt, the client asks for a new future use token to be used after the next reload. And so the cycle goes. Tokens are encrypted; stored via the Local Storage API (which is domain scoped); are one-time use, even if that use was faulty; have an expiration ttl (1h default); check change in IP; and other safety meassures. After the reconnection attempt, the client asks for a new future use token to be used after the next reload. And so the cycle goes. Tokens are encrypted; stored via the Local Storage API (which is domain scoped); are one-time use, even if that use was faulty; have an expiration ttl (1h default); they check change in IP and other safety meassures.
Or a more risky opproach is to recognize client connections by their IP (v4 or v6, whichever is used). Do this with a global flag on the SocioServer config:
```ts
//server code
const socserv = new SocioServer({ port: 3000 }, {
db: {...},
logging: {...},
auto_recon_by_ip:true // <- this
}
);
```
"Risky", because of the nature of IPv4 there are hierarchies of IP addresses, bcs there arent enough of them for billions of devices (unlike IPv6). So the IPv4 should be unique to users under the same hierarchy, e.g. same ISP, if the Socio Server is also under this same ISP. Otherwise the same IPv4 might point to multiple ppl, i.e. all devices under an ISP would have the same IPv4 visible to Socio and would treat them as the same client. Shivers me timbers. But still useful for localhost dev, so that the clients dont have to use the tokens.
This is also not needed if your framework implements CSR (client-side routing), whereby the page doesnt actually navigate or reload, but just looks like it does. This is also not needed if your framework implements CSR (client-side routing), whereby the page doesnt actually navigate or reload, but just looks like it does.

View file

@ -16,6 +16,7 @@ type QueryObjectSQL = { sql?: string, endpoint?: string, params?: object | null
type QueryObject = QueryObjectSQL & { onUpdate: SubscribeCallbackObject }; type QueryObject = QueryObjectSQL & { onUpdate: SubscribeCallbackObject };
type QueryPromise = { res: Function, prom:Promise<any> | null, start_buff: number, payload_size?:number }; type QueryPromise = { res: Function, prom:Promise<any> | null, start_buff: number, payload_size?:number };
export type ProgressOnUpdate = (percentage: number) => void; 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; type PropUpdateCallback = ((new_val: PropValue, diff?: diff_lib.rdiffResult[]) => void) | null;
export type ClientProp = { val: PropValue | undefined, subs: { [id: id]: PropUpdateCallback } }; export type ClientProp = { val: PropValue | undefined, subs: { [id: id]: PropUpdateCallback } };
@ -131,7 +132,7 @@ export class SocioClient extends LogHandler {
} }
case ClientMessageKind.AUTH:{ case ClientMessageKind.AUTH:{
this.#FindID(kind, data?.id) 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.HandleInfo(`AUTH returned FALSE, which means websocket has not authenticated.`);
this.#authenticated = data?.result as Bit === 1; this.#authenticated = data?.result as Bit === 1;
@ -189,10 +190,14 @@ export class SocioClient extends LogHandler {
break; break;
} }
case ClientMessageKind.RECON:{ case ClientMessageKind.RECON:{
this.#FindID(kind, data?.id); if(data?.id){
//@ts-expect-error this.#FindID(kind, data.id);
this.#queries.get(data.id)(data); (this.#queries.get(data.id) as QueryPromise).res(data);
this.#queries.delete(data.id); //clear memory 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; break;
} }
case ClientMessageKind.RECV_FILES:{ case ClientMessageKind.RECV_FILES:{
@ -464,15 +469,12 @@ export class SocioClient extends LogHandler {
} }
#HandleBasicPromiseMessage(kind: ClientMessageKind, data:ClientMessageDataObj){ #HandleBasicPromiseMessage(kind: ClientMessageKind, data:ClientMessageDataObj){
this.#FindID(kind, data?.id); this.#FindID(kind, data?.id);
const q = this.#queries.get(data.id); const q = this.#queries.get(data.id) as QueryObject | QueryPromise;
// @ts-expect-error if (q.hasOwnProperty('res'))
if(q?.res)
(q as QueryPromise).res(data?.result as any); (q as QueryPromise).res(data?.result as any);
// @ts-expect-error else if (q.hasOwnProperty('onUpdate') && (q as QueryObject).onUpdate?.success)
else if (q?.onUpdate) //@ts-expect-error
if ((q as QueryObject)?.onUpdate?.success) q.onUpdate.success(data?.result as Bit);
// @ts-expect-error
(q as QueryObject).onUpdate.success(data?.result as Bit);
this.#queries.delete(data.id); //clear memory 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 //ask the server for a reconnection to an old session via our one-time token
this.Send(CoreMessageKind.RECON, { id, data: { type: 'POST', token } }); this.Send(CoreMessageKind.RECON, { id, data: { type: 'POST', token } });
const res = await prom; const res = await (prom as unknown as Promise<ReconData>);
this.#Reconnect(res); //sets the trusted values from the server, like auth bool
//@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));
} }
} }
//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 // for dev debug, if u want
LogMaps(){ 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 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 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 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 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 }; 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. //whereas public variables are free for you to alter freely at any time during runtime.
export class SocioServer extends LogHandler { export class SocioServer extends LogHandler {
// private: //---private:
#wss: WebSocketServer; #wss: WebSocketServer;
#sessions: Map<ClientID, SocioSession> = new Map(); //Maps are quite more performant than objects. And their keys dont overlap with Object prototype. #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. //global flag to send prop obj diffs using the diff lib instead of the full object every time.
#prop_upd_diff = false; #prop_upd_diff = false;
//public: //---public:
db!: DBOpts; 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 }; 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; 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'}); super({ ...logging, prefix:'SocioServer'});
//verbose - print stuff to the console using my lib. Doesnt affect the log handlers //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. //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 //both are public and settable at runtime
//private: //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.#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 }; this.#secure = { socio_security, ...decrypt_opts };
@ -84,6 +85,7 @@ export class SocioServer extends LogHandler {
this.db = db; this.db = db;
this.session_defaults = Object.assign(this.session_defaults, session_defaults); this.session_defaults = Object.assign(this.session_defaults, session_defaults);
this.prop_reg_timeout_ms = prop_reg_timeout_ms; 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('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) }); 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; const addr: AddressInfo = this.#wss.address() as AddressInfo;
if (this.verbose) this.done(`Created SocioServer on `, addr); if (this.verbose) this.done(`Created SocioServer on `, addr);
// if (addr.family == 'ws') if (addr.family == 'ws')
// this.HandleInfo('WARNING! Your server is using an unsecure WebSocket protocol, setup wss:// instead, when you can!'); this.HandleInfo('WARNING! Your server is using an unsecure WebSocket protocol, setup wss:// instead, when you can!');
} }
async #Connect(conn: WebSocket, request: IncomingMessage){ async #Connect(conn: WebSocket, request: IncomingMessage){
@ -118,10 +120,6 @@ export class SocioServer extends LogHandler {
if (this.#lifecycle_hooks.con) 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 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 //set this client websockets event handlers
conn.on('message', (req: Buffer | ArrayBuffer | Buffer[], isBinary: Boolean) => { conn.on('message', (req: Buffer | ArrayBuffer | Buffer[], isBinary: Boolean) => {
if (this.#sessions.has(client_id))//@ts-expect-error 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('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 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); } } 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 case CoreMessageKind.AUTH: {//client requests to authenticate itself with the server
if (client.authenticated) //check if already has auth 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) { 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 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 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 { } else {
this.HandleError('AUTH function hook not registered, so client not authenticated. [#no-auth-func]') this.HandleError('AUTH function hook not registered, so client not authenticated. [#no-auth-func]');
client.Send(ClientMessageKind.AUTH, { id: data.id, result: 0 }) client.Send(ClientMessageKind.AUTH, { id: data.id, result: 0 });
} }
break; break;
} }
@ -461,22 +477,8 @@ export class SocioServer extends LogHandler {
//recon procedure //recon procedure
const old_client = this.#sessions.get(old_c_id) as SocioSession; 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 this.ReconnectClientSession(client, old_client, data.id as id);
client.CopySessionFrom(old_client); this.HandleInfo(`RECON | old id: ${old_c_id} -> new id: ${client.id}`);
//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)`);
} }
break; 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 session_ids(){return this.#sessions.keys();}
get server_info() { return this.#wss.address(); } get server_info() { return this.#wss.address(); }
get raw_websocket_server() { return this.#wss; } get raw_websocket_server() { return this.#wss; }

View file

@ -7,7 +7,7 @@ import { ClientMessageKind } from './core-client.js';
//types //types
import type { WebSocket } from 'ws'; //https://github.com/websockets/ws https://github.com/websockets/ws/blob/master/doc/ws.md 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'; import type { RateLimit } from './ratelimit.js';
export type SubObj = { export type SubObj = {
@ -90,9 +90,9 @@ export class SocioSession extends LogHandler {
} }
get authenticated() { return this.#authenticated } 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 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:boolean = await auth_func(this, params); const auth = await auth_func(this, params);
this.#authenticated = auth == true; this.#authenticated = auth === true;
return auth; return auth;
} }