Skip to content

Commit

Permalink
fix: rewrite rekey to run in background (still needs improvement)
Browse files Browse the repository at this point in the history
  • Loading branch information
titanism committed Oct 7, 2024
1 parent caf9660 commit a66f877
Show file tree
Hide file tree
Showing 32 changed files with 368 additions and 228 deletions.
11 changes: 11 additions & 0 deletions app/controllers/web/my-account/generate-alias-password.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,18 @@ async function generateAliasPassword(ctx) {
0
);

if (!ctx.api) {
ctx.flash(
'success',
ctx.translate(
'ALIAS_REKEY_STARTED',
`${alias.name}@${ctx.state.domain.name}`
)
);
}

// don't save until we're sure that sqlite operations were performed
alias.is_rekey = true;
await alias.save();

// close websocket
Expand Down
8 changes: 7 additions & 1 deletion app/models/aliases.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ APS.plugin(mongooseCommonPlugin, {
});

const Aliases = new mongoose.Schema({
// if a rekey operation is being performed then don't allow auth or read/write
is_rekey: {
type: Boolean,
default: false
},

// alias specific max quota (set by admins only)
max_quota: {
type: Number,
Expand Down Expand Up @@ -409,7 +415,7 @@ Aliases.pre('validate', function (next) {
// it populates "id" String automatically for comparisons
Aliases.plugin(mongooseCommonPlugin, {
object: 'alias',
omitExtraFields: ['is_api', 'tokens', 'pgp_error_sent_at', 'aps'],
omitExtraFields: ['is_rekey', 'is_api', 'tokens', 'pgp_error_sent_at', 'aps'],
defaultLocale: i18n.getLocale()
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ const Domains = new mongoose.Schema({
default: '25',
validator: (value) => isPort(value)
},
alias_count: {
alias_count: {
type: Number,
min: 0,
index: true
Expand Down
10 changes: 10 additions & 0 deletions config/phrases.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ module.exports = {
'You have exceeded the maximum number of failed authentication attempts. Please try again later or contact us.',
ALIAS_BACKUP_LINK:
'Please <a href="%s" target="_blank">click here</a> to download the backup. This link will expire soon.',
ALIAS_REKEY_STARTED:
'Alias password change (rekey) has been started for <span class="notranslate text-monospace font-weight-bold">%s</span> and you will be emailed upon completion.',
ALIAS_REKEY_READY:
'Alias password change (rekey) is now complete. You can now log in to IMAP, POP3, and CalDAV servers with the new password for <span class="notranslate font-weight-bold text-monospace">%s</span>.',
ALIAS_REKEY_READY_SUBJECT:
'Alias password change (rekey) for <span class="notranslate">%s</span> is complete',
ALIAS_REKEY_FAILED_SUBJECT:
'Alias password change (rekey) for <span class="notranslate">%s</span> has failed due to an error',
ALIAS_REKEY_FAILED_MESSAGE:
'<p>The alias password change (rekey) for <span class="notranslate text-monospace font-weight-bold">%s</span> has failed and we have been alerted.</p><p>You may proceed to retry if necessary, and we may email you soon to provide help if necessary.</p><p>The error received during the rekey process was:</p><pre><code>%s</code></pre>',
ALIAS_BACKUP_READY:
'Click the button below within 4 hours to download the <span class="notranslate">"%s"</span> backup for <span class="notranslate font-weight-bold text-monospace">%s</span>.<br /><br /><a href="%s" target="_blank" rel="noopener noreferrer" class="btn btn-dark btn-lg">Download Now</a>',
ALIAS_BACKUP_READY_SUBJECT:
Expand Down
17 changes: 14 additions & 3 deletions helpers/on-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ async function onAuth(auth, session, fn) {
'user',
`id ${config.userFields.isBanned} ${config.userFields.smtpLimit} email ${config.lastLocaleField} timezone`
)
.select('+tokens.hash +tokens.salt')
.select('+tokens.hash +tokens.salt +is_rekey')
.lean()
.exec();

Expand All @@ -203,11 +203,22 @@ async function onAuth(auth, session, fn) {
// validate the `auth.password` provided
//

// IMAP and POP3 servers can only validate against aliases
// IMAP/POP3/CalDAV servers can only validate against aliases
if (
this.server instanceof IMAPServer ||
this.server instanceof POP3Server
this.server instanceof POP3Server ||
this?.constructor?.name === 'CalDAV'
) {
if (typeof alias.is_rekey === 'boolean' && alias.is_rekey === true)
throw new SMTPError(
'Alias is undergoing a rekey operation, please try again once completed',
{
responseCode: 535,
ignoreHook: true,
imapResponse: 'AUTHENTICATIONFAILED'
}
);

if (!Array.isArray(alias.tokens) || alias?.tokens?.length === 0)
throw new SMTPError(
`Alias does not have a generated password yet, go to ${
Expand Down
120 changes: 14 additions & 106 deletions helpers/parse-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -1504,131 +1504,39 @@ async function parsePayload(data, ws) {
const cache = await this.client.get(
`reset_check:${payload.session.user.alias_id}`
);

if (cache)
throw Boom.clientTimeout(
i18n.translateError('RATE_LIMITED', payload.session.user.locale)
);

await this.client.set(
`reset_check:${payload.session.user.alias_id}`,
true,
'PX',
ms('30s')
);

// check if file path was <= initial db size
// (and if so then perform the same logic as in "reset")
let reset = false;
if (!stats || stats.size <= config.INITIAL_DB_SIZE) {
try {
await fs.promises.rm(storagePath, {
force: true,
recursive: true
});
} catch (err) {
if (err.code !== 'ENOENT') {
err.isCodeBug = true;
throw err;
}
}

// -wal
try {
await fs.promises.rm(
storagePath.replace('.sqlite', '.sqlite-wal'),
{
force: true,
recursive: true
}
);
} catch (err) {
if (err.code !== 'ENOENT') {
err.isCodeBug = true;
throw err;
}
}

// -shm
try {
await fs.promises.rm(
storagePath.replace('.sqlite', '.sqlite-shm'),
{
force: true,
recursive: true
}
);
} catch (err) {
if (err.code !== 'ENOENT') {
err.isCodeBug = true;
throw err;
}
}

reset = true;

// close existing connection if any and purge it
if (
this?.databaseMap &&
this.databaseMap.has(payload.session.user.alias_id)
) {
await closeDatabase(
this.databaseMap.get(payload.session.user.alias_id)
);
this.databaseMap.delete(payload.session.user.alias_id);
}

// TODO: this should not fix database
db = await getDatabase(
this,
// alias
{
id: payload.session.user.alias_id,
storage_location: payload.session.user.storage_location
},
{
...payload.session,
user: {
...payload.session.user,
password: payload.new_password
}
}
);
}

//
// NOTE: if we're not resetting database then assume we want to do a backup
// NOTE: if maxQueue exceeded then this will error and reject
// <https://github.com/piscinajs/piscina/blob/5169c75a4d744c8503b64f6e5aaac358c4f72e6c/src/errors.ts#L5>
// (note all of these error messages are in TimeoutError checking)
//
let err;
if (!reset) {
//
// NOTE: if maxQueue exceeded then this will error and reject
// <https://github.com/piscinajs/piscina/blob/5169c75a4d744c8503b64f6e5aaac358c4f72e6c/src/errors.ts#L5>
// (note all of these error messages are in TimeoutError checking)
//
try {
// run in worker pool to offset from main thread (because of VACUUM)
await this.piscina.run(payload, { name: 'rekey' });
} catch (_err) {
err = _err;
}
}

// update storage
try {
await updateStorageUsed(payload.session.user.alias_id, this.client);
} catch (err) {
logger.fatal(err, { payload });
}

// remove write lock
// await this.client.del(`reset_check:${payload.session.user.alias_id}`);

if (err) throw err;
// run in worker pool to offset from main thread (because of VACUUM)
// and run this in the background
//
this.piscina
.run(payload, { name: 'rekey' })
.then()
.catch((err) => logger.fatal(err, { payload }));

response = {
id: payload.id,
data: true
};

this.client.publish('sqlite_auth_reset', payload.session.user.alias_id);

break;
}

Expand Down
Loading

0 comments on commit a66f877

Please sign in to comment.