diff --git a/README.md b/README.md index 3739a58..5d3432b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Configure the plugin separately for each container when using the docker run com docker run --rm --label x \ --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ --log-opt journal-fqdn=5d78e1427fd7e0228fe18f46.journal.pl-waw-1.hyperone.cloud \ - --log-opt journal-token=test \ + --log-opt journal-password=test \ -it alpine id ``` @@ -65,7 +65,11 @@ Each message has the following tags assigned by default. The user has the abilit ### Required variables * ```journal-fqdn``` – Journal FQDN that will receive logs -* ```journal-token``` – Credential (password) to journal indicated in the parameter ```journal-fqdn``` + +* access data to journal indicated in the parameter ```journal-fqdn``` (one of the following): + * password – ```journal-password``` – Credential (password) of journal + * service account – ```journal-passport``` – Content of service account passport file + * ```journal-credential-endpoint``` – credential endpoint eg. metadata service ### Optional variables @@ -74,6 +78,7 @@ Each message has the following tags assigned by default. The user has the abilit * ```env-regex``` – A regular expression to match logging-related environment variables. Used for advanced log tag options. If there is collision between the label and env keys, env wins. Disabled by default. * ```flush-buffer-size``` – How many pending messages can be collected before sending to journal immediately. Default: 500 * ```flush-interval``` – How long (in miliseconds) the buffer keeps messages before flushing them. Default: 15000 +* ```journal-unsecure``` – Use unsecure (HTTP) connection to Journal ## Development diff --git a/driver.js b/driver.js index 37367bd..1c00b89 100644 --- a/driver.js +++ b/driver.js @@ -39,11 +39,9 @@ module.exports = () => { const driver = {}; driver.startLogging = async (stream, File, Info) => { - ['journal-fqdn', 'journal-token'].forEach(name => { - if (!Info.Config[name]) { - throw new Error(`Missing '${name} option of log driver`); - } - }); + if (!Info.Config['journal-fqdn']) { + throw new Error('Missing \'journal-fqdn\' option of log driver'); + } let flush_interval = 15000; try { @@ -92,7 +90,7 @@ module.exports = () => { await log.client.checkJournalToken(); } catch (err) { console.error(err); - throw new Error('Invalid journal-token'); + throw new Error(`Invalid/missing access data for journal: ${err}`); } log.interval = setInterval(flushLogBuffer, flush_interval, log); diff --git a/journal.js b/journal.js index fb58a51..8c1b7e9 100644 --- a/journal.js +++ b/journal.js @@ -1,27 +1,88 @@ 'use strict'; const qs = require('qs'); const logger = require('superagent-logger'); +const jwt = require('jsonwebtoken'); const WebSocket = require('ws'); const { FilterJournalDockerStream, ParseJournalStream } = require('./transform'); +const timeskew = 20; + module.exports = (config) => { - const url = `https://${config['journal-fqdn']}/log`; + const proto = config['journal-unsecure'] ? 'http' : 'https'; + const url = `${proto}://${config['journal-fqdn']}/log`; const agent = require('superagent').agent().use(logger); + + let cred_req; + let expiresAt = 0; + + const get_headers = async () => { + if (config['journal-password']) { + return { 'x-auth-password': config['journal-password'] }; + } + + if (config['journal-passport']) { + const passport = JSON.parse(config['journal-passport'].trim()); + const token = jwt.sign({}, passport.private_key, { + algorithm: 'RS256', + expiresIn: '5m', + keyid: passport.certificate_id, + audience: config['journal-fqdn'], + issuer: passport.issuer, + subject: passport.subject_id, + }); + + return { + Authorization: `Bearer ${token}`, + }; + } + + if (config['journal-credential-endpoint']) { + const ts = Math.round(new Date().getTime() / 1000); + if (!cred_req) { // no credential + cred_req = agent.post(config['journal-credential-endpoint']) + .set({ Metadata: 'true' }) + .send({ audience: config['journal-fqdn'] }) + .then(resp => { + expiresAt = ts + resp.body.expires_in - timeskew; + const until = new Date(expiresAt * 1000).toISOString(); + console.log(`Access token refreshed. Valid until ${until}.`); + return resp.body; + }); + } + const credential = await cred_req; + // expired response + const exp = new Date(expiresAt * 1000).toISOString(); + if (expiresAt < ts) { + cred_req = undefined; + console.log(`Access token is old. Expired at ${exp}. Refreshing.`); + console.log(`Refreshing token for ${config['journal-fqdn']}`); + return get_headers(); + } + console.log(`Access token is fresh. Valid until ${exp}. Re-use.`); + + const token = credential.access_token; + + return { + Authorization: `Bearer ${token}`, + }; + } + + return {}; + }; + return { - checkJournalToken: () => agent + checkJournalToken: async () => agent .head(url) .query({ follow: 'false' }) - .set('x-auth-password', config['journal-token']), - send: (messages) => new Promise((resolve, reject) => { + .set(await get_headers()), + send: async (messages) => { const body = Array.isArray(messages) ? messages : [messages]; const content = body.map(x => JSON.stringify(x)).join('\n'); return agent .post(url) .send(content) - .set('x-auth-password', config['journal-token']) - .then(resolve) - .catch(reject); - }), + .set(await get_headers()); + }, read: (read_config, read_info) => new Promise((resolve, reject) => { const query = { follow: read_config.Follow, @@ -42,23 +103,26 @@ module.exports = (config) => { console.log('query', query); const ws_url = `${url}?${qs.stringify(query)}`; console.log('WS', ws_url); - const ws = new WebSocket(ws_url, { - headers: { 'x-auth-password': config['journal-token'] }, - }); + return get_headers().then(headers => { + const ws = new WebSocket(ws_url, { + headers, + }); - ws.on('open', () => { - console.log(config['journal-fqdn'], 'websocket opened'); - const stream = WebSocket.createWebSocketStream(ws). - pipe(new ParseJournalStream()). - pipe(new FilterJournalDockerStream()); - stream.pause(); - resolve(stream); - }); + ws.on('open', () => { + console.log(config['journal-fqdn'], 'websocket opened'); + const stream = WebSocket.createWebSocketStream(ws). + pipe(new ParseJournalStream()). + pipe(new FilterJournalDockerStream()); + stream.pause(); + resolve(stream); + }); + + ws.on('close', () => { + console.log(config['journal-fqdn'], 'websocket closed'); + }); + ws.on('error', reject); + }).catch(resolve); - ws.on('close', () => { - console.log(config['journal-fqdn'], 'websocket closed'); - }); - ws.on('error', reject); }), }; }; diff --git a/package-lock.json b/package-lock.json index 95fa1b5..c6fc1ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -548,6 +548,11 @@ "fill-range": "^7.0.1" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -1075,6 +1080,14 @@ "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2048,6 +2061,49 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -2255,18 +2311,53 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, "lodash.islength": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.islength/-/lodash.islength-4.0.1.tgz", "integrity": "sha1-Tpho1FJXXXUK/9NYyXlUPcIO1Xc=", "dev": true }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "log-symbols": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", @@ -3088,8 +3179,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "semver-diff": { "version": "3.1.1", diff --git a/package.json b/package.json index bf7313a..68d0e08 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "co-body": "^6.0.0", "dayjs": "^1.8.23", + "jsonwebtoken": "^8.5.1", "koa": "^2.11.0", "koa-body": "^4.1.1", "koa-logger": "^3.2.1", @@ -26,5 +27,6 @@ "@hyperone/eslint-config": "^2.0.1", "ava": "^3.5.1", "eslint": "^6.8.0" - } + }, + "eslintIgnore": ["rootfs"] } diff --git a/tests/driver.js b/tests/driver.js index 5c65616..acbc94e 100644 --- a/tests/driver.js +++ b/tests/driver.js @@ -41,7 +41,7 @@ class LogGenerator extends Readable { const defaultInfo = () => ({ Config: { 'journal-fqdn': `${process.env.JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud`, - 'journal-token': process.env.JOURNAL_TOKEN, + 'journal-password': process.env.JOURNAL_TOKEN, }, ContainerID: getRandom(), ContainerName: '/confident_carson', diff --git a/tests/e2e/e2e.bats b/tests/e2e/e2e.bats index 48b7894..3c7b3cd 100644 --- a/tests/e2e/e2e.bats +++ b/tests/e2e/e2e.bats @@ -15,12 +15,12 @@ teardown() { @test "plugin send logs" { run docker run \ - --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ - --label dockerbats="$BATS_TEST_NAME" \ - --log-opt labels=dockerbats \ - --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ - --log-opt journal-token=${JOURNAL_TOKEN} \ - alpine sh -c 'echo $RANDOM'; + --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ + --label dockerbats="$BATS_TEST_NAME" \ + --log-opt labels=dockerbats \ + --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ + --log-opt journal-password=${JOURNAL_TOKEN} \ + alpine sh -c 'echo $RANDOM'; [ "$status" -eq 0 ]; containerId=$(docker container ls -a -q --filter label=dockerbats="$BATS_TEST_NAME"); run docker logs "${containerId}"; @@ -30,12 +30,12 @@ teardown() { @test "plugin flush logs" { run docker run -d \ - --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ - --label dockerbats="$BATS_TEST_NAME" \ - --log-opt labels=dockerbats \ - --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ - --log-opt journal-token=${JOURNAL_TOKEN} \ - alpine sh -c 'seq 1 10; sleep 30'; + --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ + --label dockerbats="$BATS_TEST_NAME" \ + --log-opt labels=dockerbats \ + --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ + --log-opt journal-password=${JOURNAL_TOKEN} \ + alpine sh -c 'seq 1 10; sleep 30'; [ "$status" -eq 0 ]; # Wait for flush (15 second default) sleep 20; @@ -48,12 +48,12 @@ teardown() { @test "plugin sends multiple lines of logs" { token=${RANDOM}; run docker run \ - --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ + --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ --label dockerbats="$BATS_TEST_NAME-${token}" \ --log-opt labels=dockerbats \ - --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ - --log-opt journal-token=${JOURNAL_TOKEN} \ - alpine sh -c "seq 100 | while read line; do echo \"multiple-\${line}-${token}\"; done;"; + --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ + --log-opt journal-password=${JOURNAL_TOKEN} \ + alpine sh -c "seq 100 | while read line; do echo \"multiple-\${line}-${token}\"; done;"; [ "$status" -eq 0 ] containerId=$(docker container ls -a -q --filter label=dockerbats="${BATS_TEST_NAME}-${token}"); echo "Container id: ${containerId}"; @@ -66,21 +66,21 @@ teardown() { @test "plugin require token" { run docker run -d \ - --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ + --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ --log-opt labels=dockerbats \ --label dockerbats="$BATS_TEST_NAME" \ - alpine id; - [[ $output =~ "Missing 'journal-fqdn option of log driver." ]] + alpine id; + [[ $output =~ "Missing 'journal-fqdn' option of log driver" ]] [ "$status" -eq 125 ] } @test "plugin validate token" { run docker run -d \ - --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ + --log-driver 'h1cr.io/h1-docker-logging-plugin:latest' \ --label dockerbats="$BATS_TEST_NAME" \ - --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ - --log-opt journal-token="invalid token" \ - alpine id; - [[ $output =~ "Invalid journal-token." ]] + --log-opt journal-fqdn=${JOURNAL_ID}.journal.pl-waw-1.hyperone.cloud \ + --log-opt journal-password="invalid token" \ + alpine id; + [[ $output =~ "Invalid/missing access data for journal" ]] [ "$status" -eq 125 ] } \ No newline at end of file diff --git a/tests/util.js b/tests/util.js index 04965e3..14b58d0 100644 --- a/tests/util.js +++ b/tests/util.js @@ -7,7 +7,7 @@ const demo = { Info: { Config: { 'journal-fqdn': '5d78e1427fd7e0228fe18f46.journal.pl-waw-1.hyperone.cloud', - 'journal-token': 'x', + 'journal-password': 'x', }, ContainerID: '956d79af66ec1e85cc409d1153af23ace3b2b55a6fdfa2dc39cd80ff8e7416bf', ContainerName: '/xenodochial_jang',