Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Automatically alert about and then remove expired containers #412

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions join.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ async function handleOAuth2Code (request, response, next) {
// If they match, use the code to request an OAuth2 access token.
try {
// Associate the new OAuth2 access token to the current session.
// TODO: Also save the `refreshToken` when it's fully supported.
const { accessToken } = await getOAuth2AccessToken(code, state);
oauth2Tokens[session.id] = accessToken;
oauth2Tokens[session.id] = await getOAuth2AccessToken(code, state);

// Consider that this OAuth2 access token expires at some point.
//const created = Date.now();
//const expires = created + 1000 * 3600 * 30; // 30 hours from now.
} catch (error) {
log('[fail] oauth2 access token', error);
response.statusCode = 403; // Forbidden
Expand All @@ -154,10 +156,15 @@ async function handleOAuth2Code (request, response, next) {
async function ensureOAuth2Access (request, response, next) {
const { session } = request;
if (oauth2Tokens[session.id]) {
// This session has an OAuth2 access token, so it's authenticated.
// TODO: Also verify that the token is still valid, or renew it if not.
next();
return;
const expires = oauth2Tokens[session.id].expires;
if (expires && expires > Date.now()) {
// This session has a valid OAuth2 access token, so it's authenticated.
next();
return;
}

// This sessions's OAuth2 access token has expired.
delete oauth2Tokens[session.id];
}

// We can only use `http.ServerResponse`s to initiate OAuth2 authentication,
Expand Down Expand Up @@ -254,7 +261,7 @@ function routeRequest (proxyParameters, request, response) {
}

// Use the Janitor API to get the mapping information of a given container port.
async function getMappedPort (accessToken, container, port) {
async function getMappedPort ({ accessToken }, container, port) {
const parameters = {
provider: 'janitor',
accessToken: accessToken,
Expand Down
19 changes: 16 additions & 3 deletions lib/hosts.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,15 @@ exports.issueOAuth2AccessToken = async function (request) {
const { scope: grant, token, tokenHash } = data;
const scope = stringifyScopes(grant.scopes);

// Make this OAuth2 authorization expire at some point.
const created = Date.now();
const expires = created + 1000 * 3600 * 30; // 30 hours from now.

const authorization = {
client: client_id,
date: Date.now(),
email: grant.email,
created,
expires,
scope,
};

Expand All @@ -222,7 +227,7 @@ exports.issueOAuth2AccessToken = async function (request) {
db.save();

log(hostname, 'was granted access to', scope, 'by', authorization.email);
return { access_token: token, scope };
return { access_token: token, created, expires, scope };
};

// Find the OAuth2 access scope authorized for a request's access token.
Expand All @@ -246,7 +251,15 @@ exports.getOAuth2Scope = function (request) {
return null;
}

const { client, email, scope } = authorization;
const { client, email, scope, expires = null } = authorization;
if (!(expires && expires > Date.now())) {
// The OAuth2 authorization has expired, delete it.
log('[fail] deleting expired token:', authorization);
delete oauth2tokens[tokenHash];
db.save();
return null;
}

const hostname = exports.identify(client);
if (!hostname) {
// The authorized OAuth2 client doesn't exist anymore.
Expand Down
12 changes: 12 additions & 0 deletions lib/machines.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const jsonpatch = require('fast-json-patch');

const db = require('./db');
const docker = require('./docker');
const events = require('./events');
const log = require('./log');
const metrics = require('./metrics');
const streams = require('./streams');
Expand Down Expand Up @@ -217,6 +218,7 @@ exports.spawn = function (user, projectId, callback) {
machine.status = 'started';

const now = Date.now();
metrics.set(machine, 'spawned', now);
metrics.push(project, 'spawn-time', [ now, now - time ]);

const expires = now + maximumContainerLifetime;
Expand Down Expand Up @@ -292,6 +294,16 @@ exports.destroy = function (user, projectId, machineId, callback) {
});
};

// Destroy user containers when they expire.
events.on('ContainerExpired', ({ email, host, container }) => {
log('[event] container expired', email, host, container);
// FIXME: Actually destroy the container, but only once:
// 1. Container expiration is made sufficiently obvious on the website
// 2. Email reminders are sent some time before actual expiration
// 3. Users have been given ample time to save their work (e.g. by pushing
// commits to a remote repository, or by migrating to a new container).
});

// Install or overwrite a configuration file in all the user's containers.
exports.deployConfigurationInAllContainers = function (user, file) {
let count = 0;
Expand Down
2 changes: 1 addition & 1 deletion lib/oauth2.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ exports.getAccessToken = async function (parameters) {
return;
}

resolve({ accessToken, refreshToken });
resolve({ accessToken, refreshToken, results }); // expires ?
};

client.getOAuthAccessToken(code, options, onResults);
Expand Down
73 changes: 71 additions & 2 deletions lib/users.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Copyright © 2016 Jan Keromnes. All rights reserved.
// The following code is covered by the AGPL-3.0 license.

const nodemailer = require('nodemailer');
const timeago = require('timeago.js');

const certificates = require('./certificates');
const configurations = require('./configurations');
const db = require('./db');
Expand All @@ -14,6 +17,7 @@ const hostnames = db.get('hostnames', [ 'localhost' ]);
const security = db.get('security');
const baseUrl = (security.forceHttp ? 'http' : 'https') + '://' + hostnames[0] +
(hostnames[0] === 'localhost' ? ':' + db.get('ports').https : '');
const transport = nodemailer.createTransport(db.get('mailer'));

// Get a user for the current session.
exports.get = function (request, callback) {
Expand Down Expand Up @@ -51,6 +55,25 @@ exports.get = function (request, callback) {
});
};

// Find an existing user with the given primary or alternative email address.
exports.getUserByEmail = function (email) {
const users = db.get('users');

// Look for a primary email address.
if (email in users) {
return users[email];
}

// Search among alternative email addresses.
for (const user of users) {
if (user.emails.includes(email) || user.keys.github.emails.includes(email)) {
return user;
}
}

return null;
};

// Destroy the current session.
exports.logout = function (request, callback) {
sessions.destroy(request, error => {
Expand Down Expand Up @@ -158,7 +181,7 @@ exports.sendLoginEmail = function (email, request, callback) {
// Login email template.
const template = {
subject () {
return 'Janitor Sign-in link';
return 'Janitor sign-in link';
},
htmlMessage (key) {
const url = baseUrl + '/?key=' + encodeURIComponent(key);
Expand Down Expand Up @@ -200,7 +223,7 @@ exports.sendInviteEmail = function (email, callback) {
// Invite email template.
const template = {
subject () {
return 'Janitor Invite';
return 'Janitor invite';
},
htmlMessage (key) {
const url = baseUrl + '/projects/?key=' + encodeURIComponent(key);
Expand Down Expand Up @@ -241,6 +264,52 @@ exports.sendInviteEmail = function (email, callback) {
});
};

// Remind a user that one of their containers is about to expire.
exports.sendContainerExpiryReminderEmail = async function (user, container, expires) {
const expiresFuzzy = timeago().format(expires); // E.g. "in 3 days".
const expiresExact = (new Date(expires)).toUTCString().replace(/GMT/g, 'UTC');
const subject = 'Janitor expiration notice for container "' + container + '"';
const html = '<p>Hello,</p>\n' +
'<p>Your Janitor container "' + container + '" will expire ' + expiresFuzzy +
' (on ' + expiresExact + '):</p>\n' +
'<p><a href="https://janitor.technology/containers/">View your containers</a></p>\n' +
'<p>Please back up your work before then, e.g. by moving it into a new container, ' +
'or you will lose any unexported changes.</p>\n' +
'<p>For any questions or support, please visit ' +
'<a href="https://discourse.janitor.technology/">our Discourse forum</a>.</p>\n' +
'<p>Thanks!</p>\n';
const text = 'Hello,\n\n' +
'Your Janitor container "' + container + '" will expire ' + expiresFuzzy +
' (on ' + expiresExact + '):\n\n' +
'https://janitor.technology/containers/\n\n' +
'Please back up your work before then, e.g. by moving it into a new container, ' +
'or you will lose any unexported changes.\n\n' +
'For any questions or support, please visit https://discourse.janitor.technology/.\n\n' +
'Thanks!\n';
return sendEmail(user, subject, html, text);
};

// Send a custom email to a user.
function sendEmail (user, subject, html, text) {
const options = {
from: db.get('mailer').from,
to: user._primaryEmail,
subject,
html,
text
};

return new Promise((resolve, reject) => {
transport.sendMail(options, (error, info) => {
if (error) {
reject(error);
return;
}
resolve(info);
});
});
}

// Find an existing user, or create a new one.
function getOrCreateUser (email) {
const users = db.get('users');
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"le-acme-core": "2.1.4",
"ms-rest-azure": "2.5.9",
"node-forge": "0.7.6",
"nodemailer": "4.4.2",
"oauth": "0.9.15",
"oauth2provider": "0.0.2",
"selfapi": "1.0.0",
Expand Down
2 changes: 1 addition & 1 deletion static/js/janitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ function getFormData (form) {
}, {});
}

// Setup editable labels.
// Setup editable container labels.
Array.forEach(document.querySelectorAll('.editable-label'), function (label) {
const toggle = label.querySelector('.editable-toggle');
if (!toggle) {
Expand Down
26 changes: 22 additions & 4 deletions static/js/projects.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
// Copyright © 2016 Jan Keromnes. All rights reserved.
// The following code is covered by the AGPL-3.0 license.

// Add status badges to elements with a 'data-status' attribute.
// Spawn a project-specific machine when one of its links is clicked.
Array.forEach(document.querySelectorAll('.new-container'), function (form) {
window.setupAsyncForm(form);
form.addEventListener('submit', function (event) {
var url = '/api/hosts/' + form.dataset.host +
'/containers?project=' + form.dataset.project;
window.fetchAPI('PUT', url, null, function(error, data) {
if (error) {
window.updateFormStatus(form, 'error', String(error));
return;
}
window.updateFormStatus(form, 'success', null);
setTimeout(function () {
document.location.href =
'/containers/#' + data.container.slice(0, 16);
}, 400);
});
});
});

// Add status badges to elements with a 'data-status' attribute.
Array.map(document.querySelectorAll('*[data-status]'), function (element) {
var status = element.dataset.status;

Expand All @@ -23,9 +42,8 @@ Array.map(document.querySelectorAll('*[data-status]'), function (element) {
});

// Add fuzzy timestamps to elements with a 'data-timestamp' attribute.
var timestampElements = document.querySelectorAll('[data-timestamp]');
Array.forEach(timestampElements, function (element) {
var date = new Date(parseInt(element.dataset.timestamp));
Array.forEach(document.querySelectorAll('*[data-timestamp]'), function (element) {
var date = new Date(parseInt(element.dataset.timestamp, 10));

// GMT is deprecated (see https://en.wikipedia.org/wiki/UTC).
element.title = date.toUTCString().replace('GMT', 'UTC');
Expand Down