2015-07-20 00:09:47 -07:00
'use strict' ;
exports = module . exports = {
2021-01-21 11:31:35 -08:00
start ,
2021-07-07 12:59:17 -07:00
stop ,
_MOCK _APP : null
2015-07-20 00:09:47 -07:00
} ;
2021-08-19 21:39:27 -07:00
const addonConfigs = require ( './addonconfigs.js' ) ,
assert = require ( 'assert' ) ,
2016-02-18 16:04:53 +01:00
apps = require ( './apps.js' ) ,
2019-10-24 11:13:48 -07:00
BoxError = require ( './boxerror.js' ) ,
2019-10-24 13:41:41 -07:00
constants = require ( './constants.js' ) ,
2015-07-20 00:09:47 -07:00
debug = require ( 'debug' ) ( 'box:ldap' ) ,
2016-04-30 23:16:37 -07:00
eventlog = require ( './eventlog.js' ) ,
2020-11-12 23:25:33 -08:00
groups = require ( './groups.js' ) ,
2016-05-29 17:25:23 -07:00
ldap = require ( 'ldapjs' ) ,
2018-01-22 20:35:08 +01:00
mail = require ( './mail.js' ) ,
2019-03-22 15:42:16 -07:00
safe = require ( 'safetydance' ) ,
2021-09-07 09:57:49 -07:00
users = require ( './users.js' ) ,
util = require ( 'util' ) ;
2015-07-20 00:09:47 -07:00
var gServer = null ;
2021-07-07 12:59:17 -07:00
const NOOP = function ( ) { } ;
2015-07-20 00:09:47 -07:00
2021-07-07 12:59:17 -07:00
const GROUP _USERS _DN = 'cn=users,ou=groups,dc=cloudron' ;
const GROUP _ADMINS _DN = 'cn=admins,ou=groups,dc=cloudron' ;
2015-08-12 15:31:44 +02:00
2018-09-03 15:38:50 +02:00
// Will attach req.app if successful
2021-08-20 09:19:44 -07:00
async function authenticateApp ( req , res , next ) {
2021-07-07 12:59:17 -07:00
const sourceIp = req . connection . ldap . id . split ( ':' ) [ 0 ] ;
2018-09-03 15:38:50 +02:00
if ( sourceIp . split ( '.' ) . length !== 4 ) return next ( new ldap . InsufficientAccessRightsError ( 'Missing source identifier' ) ) ;
2016-02-18 16:04:53 +01:00
2021-07-07 12:59:17 -07:00
// this is only used by the ldap test. the apps tests still uses proper docker
if ( constants . TEST && sourceIp === '127.0.0.1' ) {
req . app = exports . _MOCK _APP ;
return next ( ) ;
}
2021-08-20 09:19:44 -07:00
const [ error , app ] = await safe ( apps . getByIpAddress ( sourceIp ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . message ) ) ;
if ( ! app ) return next ( new ldap . OperationsError ( 'Could not detect app source' ) ) ;
2016-06-17 10:08:41 -05:00
2021-08-20 09:19:44 -07:00
req . app = app ;
2016-06-17 10:08:41 -05:00
2021-08-20 09:19:44 -07:00
next ( ) ;
2016-02-18 16:04:53 +01:00
}
2022-01-07 14:06:13 +01:00
// Will attach req.user if successful
2021-12-23 21:31:48 +01:00
async function userAuthInternal ( appId , req , res , next ) {
// extract the common name which might have different attribute names
const attributeName = Object . keys ( req . dn . rdns [ 0 ] . attrs ) [ 0 ] ;
const commonName = req . dn . rdns [ 0 ] . attrs [ attributeName ] . value ;
if ( ! commonName ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
let verifyFunc ;
if ( attributeName === 'mail' ) {
verifyFunc = users . verifyWithEmail ;
} else if ( commonName . indexOf ( '@' ) !== - 1 ) { // if mail is specified, enforce mail check
verifyFunc = users . verifyWithEmail ;
} else if ( commonName . indexOf ( 'uid-' ) === 0 ) {
verifyFunc = users . verify ;
} else {
verifyFunc = users . verifyWithUsername ;
}
const [ error , user ] = await safe ( verifyFunc ( commonName , req . credentials || '' , appId || '' ) ) ;
if ( error && error . reason === BoxError . NOT _FOUND ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
if ( error && error . reason === BoxError . INVALID _CREDENTIALS ) return next ( new ldap . InvalidCredentialsError ( req . dn . toString ( ) ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . message ) ) ;
req . user = user ;
next ( ) ;
}
2021-08-20 09:19:44 -07:00
async function getUsersWithAccessToApp ( req ) {
2018-09-03 15:38:50 +02:00
assert . strictEqual ( typeof req . app , 'object' ) ;
2021-07-15 09:50:11 -07:00
2021-08-20 09:19:44 -07:00
const result = await users . list ( ) ;
2022-01-21 21:07:33 -08:00
const allowedUsers = result . filter ( ( user ) => user . active && apps . canAccess ( req . app , user ) ) ; // do not list inactive users
2021-08-20 09:19:44 -07:00
return allowedUsers ;
2016-05-12 13:36:53 -07:00
}
2015-07-20 00:09:47 -07:00
2017-10-27 01:25:07 +02:00
// helper function to deal with pagination
function finalSend ( results , req , res , next ) {
2021-08-20 09:19:44 -07:00
let min = 0 ;
let max = results . length ;
let cookie = null ;
let pageSize = 0 ;
2017-10-27 01:25:07 +02:00
// check if this is a paging request, if so get the cookie for session info
req . controls . forEach ( function ( control ) {
if ( control . type === ldap . PagedResultsControl . OID ) {
pageSize = control . value . size ;
cookie = control . value . cookie ;
}
} ) ;
function sendPagedResults ( start , end ) {
start = ( start < min ) ? min : start ;
end = ( end > max || end < min ) ? max : end ;
2021-08-20 09:19:44 -07:00
let i ;
2017-10-27 01:25:07 +02:00
for ( i = start ; i < end ; i ++ ) {
res . send ( results [ i ] ) ;
}
return i ;
}
if ( cookie && Buffer . isBuffer ( cookie ) ) {
// we have pagination
var first = min ;
if ( cookie . length !== 0 ) {
first = parseInt ( cookie . toString ( ) , 10 ) ;
}
var last = sendPagedResults ( first , first + pageSize ) ;
var resultCookie ;
if ( last < max ) {
2019-03-21 20:06:14 -07:00
resultCookie = Buffer . from ( last . toString ( ) ) ;
2017-10-27 01:25:07 +02:00
} else {
2019-03-21 20:06:14 -07:00
resultCookie = Buffer . from ( '' ) ;
2017-10-27 01:25:07 +02:00
}
res . controls . push ( new ldap . PagedResultsControl ( {
value : {
size : pageSize , // correctness not required here
cookie : resultCookie
}
} ) ) ;
} else {
// no pagination simply send all
results . forEach ( function ( result ) {
res . send ( result ) ;
} ) ;
}
// all done
res . end ( ) ;
next ( ) ;
}
2021-08-20 09:19:44 -07:00
async function userSearch ( req , res , next ) {
2017-03-13 11:09:12 +01:00
debug ( 'user search: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2021-08-20 09:19:44 -07:00
const [ error , result ] = await safe ( getUsersWithAccessToApp ( req ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
2017-10-27 01:25:07 +02:00
2021-08-20 09:19:44 -07:00
let results = [ ] ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
// send user objects
result . forEach ( function ( user ) {
// skip entries with empty username. Some apps like owncloud can't deal with this
if ( ! user . username ) return ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
const dn = ldap . parseDN ( 'cn=' + user . id + ',ou=users,dc=cloudron' ) ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
const memberof = [ GROUP _USERS _DN ] ;
if ( users . compareRoles ( user . role , users . ROLE _ADMIN ) >= 0 ) memberof . push ( GROUP _ADMINS _DN ) ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
const displayName = user . displayName || user . username || '' ; // displayName can be empty and username can be null
const nameParts = displayName . split ( ' ' ) ;
const firstName = nameParts [ 0 ] ;
const lastName = nameParts . length > 1 ? nameParts [ nameParts . length - 1 ] : '' ; // choose last part, if it exists
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
const obj = {
dn : dn . toString ( ) ,
attributes : {
2022-01-14 14:31:33 -08:00
objectclass : [ 'user' , 'inetorgperson' , 'person' , 'organizationalperson' , 'top' ] ,
2021-08-20 09:19:44 -07:00
objectcategory : 'person' ,
2022-02-18 17:31:02 +01:00
cn : displayName ,
2021-08-20 09:19:44 -07:00
uid : user . id ,
entryuuid : user . id , // to support OpenLDAP clients
mail : user . email ,
mailAlternateAddress : user . fallbackEmail ,
displayname : displayName ,
givenName : firstName ,
username : user . username ,
samaccountname : user . username , // to support ActiveDirectory clients
memberof : memberof
}
} ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
// http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString
// which is required to have atleast one character if present
if ( lastName . length !== 0 ) obj . attributes . sn = lastName ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
if ( ( req . dn . equals ( dn ) || req . dn . parentOf ( dn ) ) && lowerCaseFilter . matches ( obj . attributes ) ) {
results . push ( obj ) ;
}
2017-03-13 11:09:12 +01:00
} ) ;
2021-08-20 09:19:44 -07:00
finalSend ( results , req , res , next ) ;
2017-03-13 11:09:12 +01:00
}
2021-08-20 09:19:44 -07:00
async function groupSearch ( req , res , next ) {
2016-05-16 14:14:58 -07:00
debug ( 'group search: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2016-05-12 13:36:53 -07:00
2021-12-09 17:23:14 +01:00
const [ error , usersWithAccess ] = await safe ( getUsersWithAccessToApp ( req ) ) ;
2021-08-20 09:19:44 -07:00
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
2016-05-12 13:36:53 -07:00
2021-08-20 09:19:44 -07:00
const results = [ ] ;
2017-10-27 01:25:07 +02:00
2021-12-09 15:07:30 +01:00
// those are the old virtual groups for backwards compat
const virtualGroups = [ {
2021-08-20 09:19:44 -07:00
name : 'users' ,
admin : false
} , {
name : 'admins' ,
admin : true
} ] ;
2017-03-13 11:09:12 +01:00
2021-12-09 15:07:30 +01:00
virtualGroups . forEach ( function ( group ) {
2021-08-20 09:19:44 -07:00
const dn = ldap . parseDN ( 'cn=' + group . name + ',ou=groups,dc=cloudron' ) ;
2021-12-09 17:23:14 +01:00
const members = group . admin ? usersWithAccess . filter ( function ( user ) { return users . compareRoles ( user . role , users . ROLE _ADMIN ) >= 0 ; } ) : usersWithAccess ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
const obj = {
dn : dn . toString ( ) ,
attributes : {
objectclass : [ 'group' ] ,
cn : group . name ,
2021-12-09 17:23:14 +01:00
memberuid : members . map ( function ( entry ) { return entry . id ; } ) . sort ( )
2017-03-13 11:09:12 +01:00
}
2021-08-20 09:19:44 -07:00
} ;
2017-03-13 11:09:12 +01:00
2021-08-20 09:19:44 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
if ( ( req . dn . equals ( dn ) || req . dn . parentOf ( dn ) ) && lowerCaseFilter . matches ( obj . attributes ) ) {
results . push ( obj ) ;
}
2015-07-20 00:09:47 -07:00
} ) ;
2021-08-20 09:19:44 -07:00
2021-12-09 17:23:14 +01:00
let [ errorGroups , resultGroups ] = await safe ( groups . listWithMembers ( ) ) ;
2021-12-09 15:07:30 +01:00
if ( errorGroups ) return next ( new ldap . OperationsError ( errorGroups . toString ( ) ) ) ;
2021-12-09 17:23:14 +01:00
if ( req . app . accessRestriction && req . app . accessRestriction . groups ) {
resultGroups = resultGroups . filter ( function ( g ) { return req . app . accessRestriction . groups . indexOf ( g . id ) !== - 1 ; } ) ;
}
2021-12-09 15:07:30 +01:00
2021-12-09 17:23:14 +01:00
resultGroups . forEach ( function ( group ) {
2021-12-09 15:07:30 +01:00
const dn = ldap . parseDN ( 'cn=' + group . name + ',ou=groups,dc=cloudron' ) ;
2021-12-09 17:23:14 +01:00
const members = group . userIds . filter ( function ( uid ) { return usersWithAccess . map ( function ( u ) { return u . id ; } ) . indexOf ( uid ) !== - 1 ; } ) ;
2021-12-09 15:07:30 +01:00
const obj = {
dn : dn . toString ( ) ,
attributes : {
objectclass : [ 'group' ] ,
cn : group . name ,
2021-12-09 17:23:14 +01:00
memberuid : members
2021-12-09 15:07:30 +01:00
}
} ;
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
if ( ( req . dn . equals ( dn ) || req . dn . parentOf ( dn ) ) && lowerCaseFilter . matches ( obj . attributes ) ) {
results . push ( obj ) ;
}
} ) ;
2021-08-20 09:19:44 -07:00
finalSend ( results , req , res , next ) ;
2016-05-12 13:36:53 -07:00
}
2021-08-20 09:19:44 -07:00
async function groupUsersCompare ( req , res , next ) {
2017-10-24 01:35:35 +02:00
debug ( 'group users compare: dn %s, attribute %s, value %s (from %s)' , req . dn . toString ( ) , req . attribute , req . value , req . connection . ldap . id ) ;
2021-08-20 09:19:44 -07:00
const [ error , result ] = await safe ( getUsersWithAccessToApp ( req ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
// we only support memberuid here, if we add new group attributes later add them here
if ( req . attribute === 'memberuid' ) {
const found = result . find ( function ( u ) { return u . id === req . value ; } ) ;
if ( found ) return res . end ( true ) ;
}
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
res . end ( false ) ;
2017-10-24 01:35:35 +02:00
}
2021-08-20 09:19:44 -07:00
async function groupAdminsCompare ( req , res , next ) {
2017-10-24 01:35:35 +02:00
debug ( 'group admins compare: dn %s, attribute %s, value %s (from %s)' , req . dn . toString ( ) , req . attribute , req . value , req . connection . ldap . id ) ;
2021-08-20 09:19:44 -07:00
const [ error , result ] = await safe ( getUsersWithAccessToApp ( req ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
// we only support memberuid here, if we add new group attributes later add them here
if ( req . attribute === 'memberuid' ) {
var user = result . find ( function ( u ) { return u . id === req . value ; } ) ;
if ( user && users . compareRoles ( user . role , users . ROLE _ADMIN ) >= 0 ) return res . end ( true ) ;
}
2017-10-24 01:35:35 +02:00
2021-08-20 09:19:44 -07:00
res . end ( false ) ;
2017-10-24 01:35:35 +02:00
}
2021-08-17 15:45:57 -07:00
async function mailboxSearch ( req , res , next ) {
2016-09-26 10:18:58 -07:00
debug ( 'mailbox search: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2016-05-29 18:24:54 -07:00
2018-05-03 18:05:32 +02:00
// if cn is set we only search for one mailbox specifically
if ( req . dn . rdns [ 0 ] . attrs . cn ) {
2021-08-17 15:45:57 -07:00
const email = req . dn . rdns [ 0 ] . attrs . cn . value . toLowerCase ( ) ;
const parts = email . split ( '@' ) ;
2018-05-03 18:05:32 +02:00
if ( parts . length !== 2 ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2017-10-27 01:25:07 +02:00
2021-08-17 15:45:57 -07:00
const [ error , mailbox ] = await safe ( mail . getMailbox ( parts [ 0 ] , parts [ 1 ] ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
if ( ! mailbox ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
if ( ! mailbox . active ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
const obj = {
dn : req . dn . toString ( ) ,
attributes : {
objectclass : [ 'mailbox' ] ,
objectcategory : 'mailbox' ,
cn : ` ${ mailbox . name } @ ${ mailbox . domain } ` ,
uid : ` ${ mailbox . name } @ ${ mailbox . domain } ` ,
mail : ` ${ mailbox . name } @ ${ mailbox . domain } `
}
} ;
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
if ( lowerCaseFilter . matches ( obj . attributes ) ) {
finalSend ( [ obj ] , req , res , next ) ;
} else {
res . end ( ) ;
}
2020-03-06 13:05:31 -08:00
} else { // new sogo
2022-02-07 16:22:05 +01:00
// TODO figure out how proper pagination here could work
let [ error , mailboxes ] = await safe ( mail . listAllMailboxes ( 1 , 100000 ) ) ;
2021-08-17 15:45:57 -07:00
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
2018-05-03 18:05:32 +02:00
2021-08-17 15:45:57 -07:00
mailboxes = mailboxes . filter ( m => m . active ) ;
2021-04-14 22:37:01 -07:00
2021-08-17 15:45:57 -07:00
let results = [ ] ;
2018-05-03 18:05:32 +02:00
2021-08-17 15:45:57 -07:00
for ( const mailbox of mailboxes ) {
const dn = ldap . parseDN ( ` cn= ${ mailbox . name } @ ${ mailbox . domain } ,ou=mailboxes,dc=cloudron ` ) ;
2021-07-15 09:50:11 -07:00
2021-12-02 22:14:41 -08:00
if ( mailbox . ownerType === mail . OWNERTYPE _APP ) continue ; // cannot login with app mailbox anyway
2021-08-17 15:45:57 -07:00
const [ error , ownerObject ] = await safe ( mailbox . ownerType === mail . OWNERTYPE _USER ? users . get ( mailbox . ownerId ) : groups . get ( mailbox . ownerId ) ) ;
if ( error || ! ownerObject ) continue ; // skip mailboxes with unknown user
2021-07-15 09:50:11 -07:00
2021-08-17 15:45:57 -07:00
const obj = {
dn : dn . toString ( ) ,
attributes : {
objectclass : [ 'mailbox' ] ,
objectcategory : 'mailbox' ,
displayname : mailbox . ownerType === mail . OWNERTYPE _USER ? ownerObject . displayName : ownerObject . name ,
cn : ` ${ mailbox . name } @ ${ mailbox . domain } ` ,
uid : ` ${ mailbox . name } @ ${ mailbox . domain } ` ,
mail : ` ${ mailbox . name } @ ${ mailbox . domain } `
}
} ;
2020-03-05 22:40:25 -08:00
2021-08-17 15:45:57 -07:00
mailbox . aliases . forEach ( function ( a , idx ) {
obj . attributes [ 'mail' + idx ] = ` ${ a . name } @ ${ a . domain } ` ;
} ) ;
2020-03-05 22:40:25 -08:00
2021-08-17 15:45:57 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
2021-07-15 09:50:11 -07:00
2021-08-17 15:45:57 -07:00
if ( ( req . dn . equals ( dn ) || req . dn . parentOf ( dn ) ) && lowerCaseFilter . matches ( obj . attributes ) ) {
results . push ( obj ) ;
2021-07-15 09:50:11 -07:00
}
2021-08-17 15:45:57 -07:00
}
2021-07-15 09:50:11 -07:00
2021-08-17 15:45:57 -07:00
finalSend ( results , req , res , next ) ;
2018-05-03 18:05:32 +02:00
}
2016-09-25 18:59:11 -07:00
}
2021-08-17 15:45:57 -07:00
async function mailAliasSearch ( req , res , next ) {
2016-09-25 18:59:11 -07:00
debug ( 'mail alias get: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2016-09-27 11:45:49 -07:00
if ( ! req . dn . rdns [ 0 ] . attrs . cn ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2017-10-27 01:25:07 +02:00
2021-08-17 15:45:57 -07:00
const email = req . dn . rdns [ 0 ] . attrs . cn . value . toLowerCase ( ) ;
const parts = email . split ( '@' ) ;
2018-01-18 18:14:31 -08:00
if ( parts . length !== 2 ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2021-08-17 15:45:57 -07:00
const [ error , alias ] = await safe ( mail . getAlias ( parts [ 0 ] , parts [ 1 ] ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
if ( ! alias ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
if ( ! alias . active ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ; // there is no way to disable an alias. this is just here for completeness
// https://wiki.debian.org/LDAP/MigrationTools/Examples
// https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
// member is fully qualified - https://docs.oracle.com/cd/E19957-01/816-6082-10/chap4.doc.html#43314
const obj = {
dn : req . dn . toString ( ) ,
attributes : {
objectclass : [ 'nisMailAlias' ] ,
objectcategory : 'nisMailAlias' ,
cn : ` ${ alias . name } @ ${ alias . domain } ` ,
rfc822MailMember : ` ${ alias . aliasName } @ ${ alias . aliasDomain } `
}
} ;
2016-09-25 18:59:11 -07:00
2021-08-17 15:45:57 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
2016-09-25 18:59:11 -07:00
2021-08-17 15:45:57 -07:00
if ( lowerCaseFilter . matches ( obj . attributes ) ) {
finalSend ( [ obj ] , req , res , next ) ;
} else {
res . end ( ) ;
}
2016-09-25 18:59:11 -07:00
}
2021-08-17 15:45:57 -07:00
async function mailingListSearch ( req , res , next ) {
2016-09-27 12:20:20 -07:00
debug ( 'mailing list get: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2016-09-25 18:59:11 -07:00
2016-09-27 12:20:20 -07:00
if ( ! req . dn . rdns [ 0 ] . attrs . cn ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2017-10-27 01:25:07 +02:00
2019-11-06 16:45:44 -08:00
let email = req . dn . rdns [ 0 ] . attrs . cn . value . toLowerCase ( ) ;
let parts = email . split ( '@' ) ;
2018-01-18 18:14:31 -08:00
if ( parts . length !== 2 ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2019-11-06 16:45:44 -08:00
const name = parts [ 0 ] , domain = parts [ 1 ] ;
2018-01-18 18:14:31 -08:00
2021-08-17 15:45:57 -07:00
const [ error , result ] = await safe ( mail . resolveList ( parts [ 0 ] , parts [ 1 ] ) ) ;
if ( error && error . reason === BoxError . NOT _FOUND ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
const { resolvedMembers , list } = result ;
if ( ! list . active ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
// http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
// members are fully qualified (https://docs.oracle.com/cd/E19444-01/816-6018-10/groups.htm#13356)
const obj = {
dn : req . dn . toString ( ) ,
attributes : {
objectclass : [ 'mailGroup' ] ,
objectcategory : 'mailGroup' ,
cn : ` ${ name } @ ${ domain } ` , // fully qualified
mail : ` ${ name } @ ${ domain } ` ,
membersOnly : list . membersOnly , // ldapjs only supports strings and string array. so this is not a bool!
mgrpRFC822MailMember : resolvedMembers // fully qualified
}
} ;
2021-04-14 22:37:01 -07:00
2021-08-17 15:45:57 -07:00
// ensure all filter values are also lowercase
const lowerCaseFilter = safe ( function ( ) { return ldap . parseFilter ( req . filter . toString ( ) . toLowerCase ( ) ) ; } , null ) ;
if ( ! lowerCaseFilter ) return next ( new ldap . OperationsError ( safe . error . toString ( ) ) ) ;
2016-05-29 18:24:54 -07:00
2021-08-17 15:45:57 -07:00
if ( lowerCaseFilter . matches ( obj . attributes ) ) {
finalSend ( [ obj ] , req , res , next ) ;
} else {
res . end ( ) ;
}
2016-05-29 18:24:54 -07:00
}
2018-09-03 15:38:50 +02:00
// Will attach req.user if successful
2021-07-15 09:50:11 -07:00
async function authenticateUser ( req , res , next ) {
2016-05-16 14:14:58 -07:00
debug ( 'user bind: %s (from %s)' , req . dn . toString ( ) , req . connection . ldap . id ) ;
2016-05-12 13:36:53 -07:00
2021-12-23 21:31:48 +01:00
const appId = req . app . id ;
2016-05-12 13:36:53 -07:00
2021-12-23 21:31:48 +01:00
await userAuthInternal ( appId , req , res , next ) ;
2016-05-29 17:16:52 -07:00
}
2021-08-20 09:19:44 -07:00
async function authorizeUserForApp ( req , res , next ) {
2018-09-03 15:38:50 +02:00
assert . strictEqual ( typeof req . user , 'object' ) ;
assert . strictEqual ( typeof req . app , 'object' ) ;
2016-05-29 17:16:52 -07:00
2021-09-21 10:00:47 -07:00
const canAccess = apps . canAccess ( req . app , req . user ) ;
2021-08-20 09:19:44 -07:00
// we return no such object, to avoid leakage of a users existence
2021-09-21 10:00:47 -07:00
if ( ! canAccess ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2016-05-12 13:36:53 -07:00
2021-08-20 09:19:44 -07:00
await eventlog . upsertLoginEvent ( eventlog . ACTION _USER _LOGIN , { authType : 'ldap' , appId : req . app . id } , { userId : req . user . id , user : users . removePrivateFields ( req . user ) } ) ;
2016-05-12 13:36:53 -07:00
2021-08-20 09:19:44 -07:00
res . end ( ) ;
2016-05-12 13:36:53 -07:00
}
2021-07-15 09:50:11 -07:00
async function verifyMailboxPassword ( mailbox , password ) {
2020-11-12 23:25:33 -08:00
assert . strictEqual ( typeof mailbox , 'object' ) ;
assert . strictEqual ( typeof password , 'string' ) ;
2021-12-02 22:14:41 -08:00
if ( mailbox . ownerType === mail . OWNERTYPE _USER ) {
return await users . verify ( mailbox . ownerId , password , users . AP _MAIL /* identifier */ ) ;
} else if ( mailbox . ownerType === mail . OWNERTYPE _GROUP ) {
const userIds = await groups . getMembers ( mailbox . ownerId ) ;
let verifiedUser = null ;
for ( const userId of userIds ) {
const [ error , result ] = await safe ( users . verify ( userId , password , users . AP _MAIL /* identifier */ ) ) ;
if ( error ) continue ; // try the next user
verifiedUser = result ;
break ; // found a matching validated user
}
2020-11-12 23:25:33 -08:00
2021-12-02 22:14:41 -08:00
if ( ! verifiedUser ) throw new BoxError ( BoxError . INVALID _CREDENTIALS ) ;
return verifiedUser ;
} else {
throw new BoxError ( BoxError . INVALID _CREDENTIALS ) ;
2021-07-15 09:50:11 -07:00
}
2020-11-12 23:25:33 -08:00
}
2021-08-20 09:19:44 -07:00
async function authenticateSftp ( req , res , next ) {
2019-04-04 20:46:01 -07:00
debug ( 'sftp auth: %s (from %s)' , req . dn . toString ( ) , req . connection . ldap . id ) ;
2019-03-19 11:59:51 -07:00
2019-03-18 21:15:50 -07:00
if ( ! req . dn . rdns [ 0 ] . attrs . cn ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2021-07-15 09:50:11 -07:00
const email = req . dn . rdns [ 0 ] . attrs . cn . value . toLowerCase ( ) ;
const parts = email . split ( '@' ) ;
2019-03-18 21:15:50 -07:00
if ( parts . length !== 2 ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2021-08-20 09:19:44 -07:00
let [ error , app ] = await safe ( apps . getByFqdn ( parts [ 1 ] ) ) ;
if ( error || ! app ) return next ( new ldap . InvalidCredentialsError ( req . dn . toString ( ) ) ) ;
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
[ error ] = await safe ( users . verifyWithUsername ( parts [ 0 ] , req . credentials , app . id ) ) ;
if ( error ) return next ( new ldap . InvalidCredentialsError ( req . dn . toString ( ) ) ) ;
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
debug ( 'sftp auth: success' ) ;
2020-03-26 21:50:25 -07:00
2021-08-20 09:19:44 -07:00
res . end ( ) ;
2019-03-18 21:15:50 -07:00
}
2021-08-20 09:19:44 -07:00
async function userSearchSftp ( req , res , next ) {
2019-04-04 20:46:01 -07:00
debug ( 'sftp user search: dn %s, scope %s, filter %s (from %s)' , req . dn . toString ( ) , req . scope , req . filter . toString ( ) , req . connection . ldap . id ) ;
2019-03-19 11:59:51 -07:00
2019-03-18 21:15:50 -07:00
if ( req . filter . attribute !== 'username' || ! req . filter . value ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2021-08-20 09:19:44 -07:00
const parts = req . filter . value . split ( '@' ) ;
2019-03-18 21:15:50 -07:00
if ( parts . length !== 2 ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
2021-08-20 09:19:44 -07:00
const username = parts [ 0 ] ;
const appFqdn = parts [ 1 ] ;
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
const [ error , app ] = await safe ( apps . getByFqdn ( appFqdn ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . toString ( ) ) ) ;
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
// only allow apps which specify "ftp" support in the localstorage addon
if ( ! safe . query ( app . manifest . addons , 'localstorage.ftp.uid' ) ) return next ( new ldap . UnavailableError ( 'Not supported' ) ) ;
if ( typeof app . manifest . addons . localstorage . ftp . uid !== 'number' ) return next ( new ldap . UnavailableError ( 'Bad uid, must be a number' ) ) ;
2019-04-04 22:38:40 -07:00
2021-08-20 09:19:44 -07:00
const uidNumber = app . manifest . addons . localstorage . ftp . uid ;
2019-03-19 21:17:23 -07:00
2021-08-20 09:19:44 -07:00
const [ userGetError , user ] = await safe ( users . getByUsername ( username ) ) ;
if ( userGetError ) return next ( new ldap . OperationsError ( userGetError . toString ( ) ) ) ;
if ( ! user ) return next ( new ldap . OperationsError ( 'Invalid username' ) ) ;
2019-03-18 21:15:50 -07:00
2021-09-21 22:42:32 -07:00
if ( ! apps . isOperator ( app , user ) ) return next ( new ldap . InsufficientAccessRightsError ( 'Not authorized' ) ) ;
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
const obj = {
dn : ldap . parseDN ( ` cn= ${ username } @ ${ appFqdn } ,ou=sftp,dc=cloudron ` ) . toString ( ) ,
attributes : {
2021-12-24 10:43:39 -08:00
homeDirectory : app . dataDir ? ` /mnt/app- ${ app . id } ` : ` /mnt/appsdata/ ${ app . id } /data ` , // see also sftp.js
2021-08-20 09:19:44 -07:00
objectclass : [ 'user' ] ,
objectcategory : 'person' ,
cn : user . id ,
uid : ` ${ username } @ ${ appFqdn } ` , // for bind after search
uidNumber : uidNumber , // unix uid for ftp access
gidNumber : uidNumber // unix gid for ftp access
}
} ;
2019-03-18 21:15:50 -07:00
2021-08-20 09:19:44 -07:00
finalSend ( [ obj ] , req , res , next ) ;
2019-03-18 21:15:50 -07:00
}
2021-09-20 19:30:00 -07:00
async function verifyAppMailboxPassword ( serviceId , username , password ) {
assert . strictEqual ( typeof serviceId , 'string' ) ;
2020-12-03 12:14:04 -08:00
assert . strictEqual ( typeof username , 'string' ) ;
assert . strictEqual ( typeof password , 'string' ) ;
2021-09-20 19:30:00 -07:00
const pattern = serviceId === 'msa' ? 'MAIL_SMTP' : 'MAIL_IMAP' ;
2021-10-08 09:59:44 -07:00
const addonId = serviceId === 'msa' ? 'sendmail' : 'recvmail' ;
const appId = await addonConfigs . getAppIdByValue ( addonId , ` % ${ pattern } _PASSWORD ` , password ) ; // search by password because this is unique for each app
2021-08-19 21:39:27 -07:00
if ( ! appId ) throw new BoxError ( BoxError . NOT _FOUND ) ;
2020-12-03 12:14:04 -08:00
2021-10-08 09:59:44 -07:00
const result = await addonConfigs . get ( appId , addonId ) ;
2020-12-03 12:14:04 -08:00
2021-08-19 21:39:27 -07:00
if ( ! result . some ( r => r . name . endsWith ( ` ${ pattern } _USERNAME ` ) && r . value === username ) ) throw new BoxError ( BoxError . INVALID _CREDENTIALS ) ;
2020-12-03 12:14:04 -08:00
}
2021-11-01 17:17:47 -07:00
async function authenticateService ( serviceId , dn , req , res , next ) {
debug ( ` authenticateService: ${ req . dn . toString ( ) } (from ${ req . connection . ldap . id } ) ` ) ;
2018-02-08 18:49:27 -08:00
2021-11-01 17:17:47 -07:00
if ( ! dn . rdns [ 0 ] . attrs . cn ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ;
2020-12-03 13:35:50 -08:00
2021-11-01 17:17:47 -07:00
const email = dn . rdns [ 0 ] . attrs . cn . value . toLowerCase ( ) ;
2020-12-03 13:35:50 -08:00
const parts = email . split ( '@' ) ;
2021-11-01 17:17:47 -07:00
if ( parts . length !== 2 ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ;
2016-09-26 11:50:32 -07:00
2021-11-01 17:17:47 -07:00
const knownServices = [ 'msa' , 'imap' , 'pop3' , 'sieve' , 'sogo' ] ;
2021-10-08 10:15:48 -07:00
if ( ! knownServices . includes ( serviceId ) ) return next ( new ldap . OperationsError ( 'Invalid DN. Unknown service' ) ) ;
2018-12-06 21:08:19 -08:00
2021-08-17 15:45:57 -07:00
const [ error , domain ] = await safe ( mail . getDomain ( parts [ 1 ] ) ) ;
if ( error ) return next ( new ldap . OperationsError ( error . message ) ) ;
2021-11-01 17:17:47 -07:00
if ( ! domain ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ;
2018-01-22 20:35:08 +01:00
2021-11-01 17:17:47 -07:00
const serviceNeedsMailbox = serviceId === 'imap' || serviceId === 'sieve' || serviceId === 'pop3' || serviceId === 'sogo' ;
if ( serviceNeedsMailbox && ! domain . enabled ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ;
2018-01-22 20:35:08 +01:00
2021-10-03 23:59:06 -07:00
const [ getMailboxError , mailbox ] = await safe ( mail . getMailbox ( parts [ 0 ] , parts [ 1 ] ) ) ;
if ( getMailboxError ) return next ( new ldap . OperationsError ( getMailboxError . message ) ) ;
2021-11-01 19:53:38 -07:00
if ( serviceNeedsMailbox ) {
if ( ! mailbox || ! mailbox . active ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ;
if ( serviceId === 'pop3' && ! mailbox . enablePop3 ) return next ( new ldap . OperationsError ( 'POP3 is not enabled' ) ) ;
2021-10-03 23:59:06 -07:00
}
2018-01-22 20:35:08 +01:00
2021-11-01 19:53:38 -07:00
const [ appPasswordError ] = await safe ( verifyAppMailboxPassword ( serviceId , email , req . credentials || '' ) ) ;
if ( ! appPasswordError ) return res . end ( ) ; // validated as app
2018-01-29 19:29:04 +01:00
2021-11-01 19:53:38 -07:00
if ( appPasswordError . reason === BoxError . INVALID _CREDENTIALS ) return next ( new ldap . InvalidCredentialsError ( dn . toString ( ) ) ) ;
if ( appPasswordError . reason !== BoxError . NOT _FOUND ) return next ( new ldap . OperationsError ( appPasswordError . message ) ) ;
2021-04-14 22:37:01 -07:00
2021-11-01 19:53:38 -07:00
if ( ! mailbox || ! mailbox . active ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ; // user auth requires active mailbox
2021-08-17 15:45:57 -07:00
const [ verifyError , result ] = await safe ( verifyMailboxPassword ( mailbox , req . credentials || '' ) ) ;
2021-11-01 17:17:47 -07:00
if ( verifyError && verifyError . reason === BoxError . NOT _FOUND ) return next ( new ldap . NoSuchObjectError ( dn . toString ( ) ) ) ;
if ( verifyError && verifyError . reason === BoxError . INVALID _CREDENTIALS ) return next ( new ldap . InvalidCredentialsError ( dn . toString ( ) ) ) ;
2021-08-17 15:45:57 -07:00
if ( verifyError ) return next ( new ldap . OperationsError ( verifyError . message ) ) ;
2018-01-22 20:35:08 +01:00
2021-08-17 15:45:57 -07:00
eventlog . upsertLoginEvent ( eventlog . ACTION _USER _LOGIN , { authType : 'ldap' , mailboxId : email } , { userId : result . id , user : users . removePrivateFields ( result ) } ) ;
2021-06-01 09:35:20 -07:00
2021-08-17 15:45:57 -07:00
res . end ( ) ;
2016-05-29 17:25:23 -07:00
}
2021-11-01 17:17:47 -07:00
async function authenticateMail ( req , res , next ) {
if ( ! req . dn . rdns [ 1 ] . attrs . ou ) return next ( new ldap . NoSuchObjectError ( req . dn . toString ( ) ) ) ;
await authenticateService ( req . dn . rdns [ 1 ] . attrs . ou . value . toLowerCase ( ) , req . dn , req , res , next ) ;
}
2021-09-07 09:57:49 -07:00
async function start ( ) {
2021-08-20 09:19:44 -07:00
const logger = {
2016-09-25 16:11:54 -07:00
trace : NOOP ,
debug : NOOP ,
info : debug ,
warn : debug ,
2020-08-02 11:43:18 -07:00
error : debug ,
fatal : debug
2016-09-25 16:11:54 -07:00
} ;
gServer = ldap . createServer ( { log : logger } ) ;
2016-05-12 13:36:53 -07:00
2019-04-25 13:10:52 +02:00
gServer . on ( 'error' , function ( error ) {
2020-08-02 11:43:18 -07:00
debug ( 'start: server error ' , error ) ;
2019-04-25 13:10:52 +02:00
} ) ;
2018-09-03 15:38:50 +02:00
gServer . search ( 'ou=users,dc=cloudron' , authenticateApp , userSearch ) ;
gServer . search ( 'ou=groups,dc=cloudron' , authenticateApp , groupSearch ) ;
gServer . bind ( 'ou=users,dc=cloudron' , authenticateApp , authenticateUser , authorizeUserForApp ) ;
2015-07-20 00:09:47 -07:00
2016-09-25 18:59:11 -07:00
// http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
2020-11-12 23:25:33 -08:00
gServer . search ( 'ou=mailboxes,dc=cloudron' , mailboxSearch ) ; // haraka (address translation), dovecot (LMTP), sogo (mailbox search)
2021-11-01 17:17:47 -07:00
gServer . bind ( 'ou=mailboxes,dc=cloudron' , async function ( req , res , next ) { // used for sogo only. this route happens only at sogo login time. after that it will use imap ldap route
await authenticateService ( 'sogo' , req . dn , req , res , next ) ;
} ) ;
2018-12-16 18:04:30 -08:00
gServer . search ( 'ou=mailaliases,dc=cloudron' , mailAliasSearch ) ; // haraka
gServer . search ( 'ou=mailinglists,dc=cloudron' , mailingListSearch ) ; // haraka
2016-09-25 18:59:11 -07:00
2021-09-20 19:30:00 -07:00
gServer . bind ( 'ou=imap,dc=cloudron' , authenticateMail ) ; // dovecot (IMAP auth)
gServer . bind ( 'ou=msa,dc=cloudron' , authenticateMail ) ; // haraka (MSA auth)
gServer . bind ( 'ou=sieve,dc=cloudron' , authenticateMail ) ; // dovecot (sieve auth)
2021-10-08 10:15:48 -07:00
gServer . bind ( 'ou=pop3,dc=cloudron' , authenticateMail ) ; // dovecot (pop3 auth)
2016-05-29 17:25:23 -07:00
2019-04-04 20:46:01 -07:00
gServer . bind ( 'ou=sftp,dc=cloudron' , authenticateSftp ) ; // sftp
2021-09-21 22:42:32 -07:00
gServer . search ( 'ou=sftp,dc=cloudron' , userSearchSftp ) ;
2019-03-18 21:15:50 -07:00
2018-09-03 15:38:50 +02:00
gServer . compare ( 'cn=users,ou=groups,dc=cloudron' , authenticateApp , groupUsersCompare ) ;
gServer . compare ( 'cn=admins,ou=groups,dc=cloudron' , authenticateApp , groupAdminsCompare ) ;
2017-10-24 01:35:35 +02:00
2016-05-12 13:20:57 -07:00
// this is the bind for addons (after bind, they might search and authenticate)
2017-11-15 18:07:10 -08:00
gServer . bind ( 'ou=addons,dc=cloudron' , function ( req , res /*, next */ ) {
2016-05-12 13:20:57 -07:00
debug ( 'addons bind: %s' , req . dn . toString ( ) ) ; // note: cn can be email or id
2016-05-11 14:26:34 -07:00
res . end ( ) ;
} ) ;
2016-05-12 13:20:57 -07:00
// this is the bind for apps (after bind, they might search and authenticate user)
2017-11-15 18:07:10 -08:00
gServer . bind ( 'ou=apps,dc=cloudron' , function ( req , res /*, next */ ) {
2015-09-25 21:17:48 -07:00
// TODO: validate password
2016-03-04 17:50:48 -08:00
debug ( 'application bind: %s' , req . dn . toString ( ) ) ;
2015-09-25 21:17:48 -07:00
res . end ( ) ;
} ) ;
2022-03-14 09:17:49 -07:00
// directus looks for the "DN" of the bind user
gServer . search ( 'ou=apps,dc=cloudron' , function ( req , res , next ) {
const obj = {
dn : req . dn . toString ( ) ,
} ;
finalSend ( [ obj ] , req , res , next ) ;
} ) ;
2021-02-13 18:56:36 +01:00
// just log that an attempt was made to unknown route, this helps a lot during app packaging
gServer . use ( function ( req , res , next ) {
debug ( 'not handled: dn %s, scope %s, filter %s (from %s)' , req . dn ? req . dn . toString ( ) : '-' , req . scope , req . filter ? req . filter . toString ( ) : '-' , req . connection . ldap . id ) ;
return next ( ) ;
} ) ;
2021-09-07 09:57:49 -07:00
await util . promisify ( gServer . listen . bind ( gServer ) ) ( constants . LDAP _PORT , '0.0.0.0' ) ;
2015-07-20 00:09:47 -07:00
}
2015-09-14 10:59:05 -07:00
2021-09-07 09:57:49 -07:00
async function stop ( ) {
2021-12-10 17:48:36 +01:00
if ( ! gServer ) return ;
gServer . close ( ) ;
gServer = null ;
2015-09-14 10:59:05 -07:00
}