1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 12:49:43 +02:00

feat: Modify logger to log to file that supports fail2ban (#284)

This commit is contained in:
Steven Correia 2022-08-22 18:42:56 -04:00 committed by GitHub
parent 5a27ac0f03
commit fe5fe5fab7
12 changed files with 427 additions and 7844 deletions

2
.gitignore vendored
View file

@ -1,5 +1,7 @@
node_modules node_modules
docker-compose.override.yml docker-compose.override.yml
/logs
.idea .idea
.DS_Store .DS_Store

View file

@ -44,6 +44,79 @@ docker-compose up -d
Demo user: demo@demo.demo demo Demo user: demo@demo.demo demo
### Logging
Planka currently allows you to expose the applciation's logfile directory to the host machine via a shared volume. This feature is not enabled by default.
To expose the logfile director to the host machine, add the item `./logs/:/app/logs/` under `services.planka.volumes`.
Note that the directory to the left of the semicolon is regarding the host machine while the directory to hte right of the semicolon is regarding the Docker container.
For example, in the above step, `./logs/:/app/logs/` will create the folder `logs` in the same directory where the `docker-compose.yml` file lives.
### Rotating Logs
logrotate is designed to ease administration of systems that generate large numbers of log files. It allows automatic rotation, compression, removal, and mailing of log files. Each log file may be handled daily, weekly, monthly, or when it grows too large.
#### Setup logrotate for Planka logs
Create a file in `/etc/logrotate.d` named `planka` with the following contents:
```
/path/to/planka/logs/planka.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 640 root adm
sharedscripts
}
```
Ensure to replace logfile directory with your installations `/logs/planka.log` location.
Restart the logrotate service.
### Fail2ban
Fail2ban is a service that uses iptables to automatically drop connections for a pre-defined amount of time from IPs that continuously failed to authenticate to the configured services.
#### Setup a filter and a jail for Planka
A filter defines regex rules to identify when users fail to authenticate on Planka's user interface.
Create a file in `/etc/fail2ban/filter.d` named `planka.conf` with the following contents:
```conf
[Definition]
failregex=^(.*) Invalid (email or username:|password!) (\"(.*)\"!)? ?\(IP: <ADDR>\)$
ignoreregex=
```
The jail file defines how to handle the failed authentication attempts found by the Planka filter.
Create a file in `/etc/fail2ban/jail.d` named `planka.local` with the following contents:
```conf
[planka]
enabled = true
port = http,https
filter = planka
logpath = /path/to/planka/logs/planka.log
maxretry = 5
bantime = 900
```
Ensure to replace `logpath`'s value with your installations `/logs/planka.log` location. If you are using ports other than 80 and 443 for your Web server you should replace those too. The bantime and findtime are defined in seconds.
Restart the fail2ban service. You can check the status of your Planka jail by running:
```bash
fail2ban-client status planka
```
## Development ## Development
Clone the repository and install dependencies: Clone the repository and install dependencies:

View file

@ -1,4 +1,9 @@
#!/bin/bash #!/bin/bash
set -e set -e
# db
node db/init.js node db/init.js
exec node app.js --prod $@
# app
export NODE_ENV=production
exec node app.js

View file

@ -1,6 +1,8 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const validator = require('validator'); const validator = require('validator');
const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = { const Errors = {
INVALID_EMAIL_OR_USERNAME: { INVALID_EMAIL_OR_USERNAME: {
invalidEmailOrUsername: 'Invalid email or username', invalidEmailOrUsername: 'Invalid email or username',
@ -41,10 +43,16 @@ module.exports = {
const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername);
if (!user) { if (!user) {
sails.log.warn(
`Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${getRemoteAddress(
this.req,
)})`,
);
throw Errors.INVALID_EMAIL_OR_USERNAME; throw Errors.INVALID_EMAIL_OR_USERNAME;
} }
if (!bcrypt.compareSync(inputs.password, user.password)) { if (!bcrypt.compareSync(inputs.password, user.password)) {
sails.log.warn(`Invalid password! (IP: ${getRemoteAddress(this.req)})`);
throw Errors.INVALID_PASSWORD; throw Errors.INVALID_PASSWORD;
} }

View file

@ -243,9 +243,7 @@ module.exports = {
* *
*/ */
log: { log: {},
level: 'debug',
},
http: { http: {
/** /**

View file

@ -64,7 +64,5 @@ module.exports = {
}, },
}, },
log: { log: {},
level: 'warn',
},
}; };

View file

@ -10,6 +10,8 @@
* https://sailsjs.com/docs/concepts/logging * https://sailsjs.com/docs/concepts/logging
*/ */
const { customLogger } = require('../utils/logger');
module.exports.log = { module.exports.log = {
/** /**
* *
@ -22,5 +24,20 @@ module.exports.log = {
* You may also set the level to "silent" to suppress all logs. * You may also set the level to "silent" to suppress all logs.
* *
*/ */
// level: 'info',
/**
* Passthrough plain log message(s) to
* custom Winston console and file logger.
*
* Note that Winston's log levels override Sails' log levels.
* Refer: https://github.com/winstonjs/winston#logging
*/
custom: customLogger,
inspect: false,
/**
* Removes the Sail.js init success logs
* (ASCII ship art) for production instances.
*/
noShip: process.env.NODE_ENV === 'production',
}; };

8002
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@
"lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'", "lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
"start": "nodemon", "start": "nodemon",
"start:prod": "node app.js --prod", "start:prod": "node app.js --prod",
"test": "mocha test/lifecycle.test.js test/integration/**/*.test.js" "test": "mocha test/lifecycle.test.js test/integration/**/*.test.js test/utils/**/*.test.js"
}, },
"eslintConfig": { "eslintConfig": {
"plugins": [ "plugins": [
@ -54,7 +54,8 @@
"sharp": "^0.30.7", "sharp": "^0.30.7",
"stream-to-array": "^2.3.0", "stream-to-array": "^2.3.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"validator": "^13.7.0" "validator": "^13.7.0",
"winston": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.3.6", "chai": "^4.3.6",

View file

@ -0,0 +1,78 @@
const { expect } = require('chai');
const { getRemoteAddress } = require('../../utils/remoteAddress');
/**
* Fake HTTP request object
* given to all api controllers.
*/
const MOCK_REQUEST = {
ip: '',
ips: [],
};
/**
* Mocks the `MOCK_REQUEST` value.
* Should be called before asserting `getRemoteAddress`.
* @param {string} ip Mock remote IP address
* @param {any[]} ips Mock array of proxy IP addresses
*/
const mockRequest = (ip, ips) => {
MOCK_REQUEST.ip = ip;
MOCK_REQUEST.ips = ips;
};
/**
* Mocks the `TRUST_PROXY` environment variable passed through `docker-compose` file.
* @param {boolean} trustProxy Whether the TRUST_PROXY environment variable was enabled.
*/
const mockProxyFlag = (trustProxy) => {
process.env.TRUST_PROXY = !!trustProxy;
};
describe('remoteAddress', () => {
describe('#getRemoteAddress(Request)', () => {
it('should get IPv4 remote address while not behind proxy and TRUST_PROXY=false', async () => {
const expectedAddress = '172.2.109.132';
mockRequest(`::ffff:${expectedAddress}`, null);
mockProxyFlag(false);
expect(getRemoteAddress(MOCK_REQUEST)).to.be.equal(expectedAddress);
});
it('should get IPv6 remote address while not behind proxy and TRUST_PROXY=false', async () => {
const expectedAddress = 'f53f:5832:9f1c:fe38:ce3d:1be8:81a2:115e';
mockRequest(expectedAddress, null);
mockProxyFlag(false);
expect(getRemoteAddress(MOCK_REQUEST)).to.be.equal(expectedAddress);
});
it('should get IPv4 remote address while behind proxy and TRUST_PROXY=true', async () => {
const expectedAddress = '172.2.109.132';
mockRequest(`::ffff:${expectedAddress}`, [
`::ffff:${expectedAddress}`,
'::ffff:192.182.23.111',
'::ffff:120.210.132.14',
]);
mockProxyFlag(true);
expect(getRemoteAddress(MOCK_REQUEST)).to.be.equal(expectedAddress);
});
it('should get IPv6 remote address while behind proxy and TRUST_PROXY=true', async () => {
const expectedAddress = 'f53f:5832:9f1c:fe38:ce3d:1be8:81a2:115e';
mockRequest(expectedAddress, [
expectedAddress,
'9d74:fb18:3b95:801f:8751:8d18:8207:b322',
'598e:4291:e1b3:2991:5d17:00af:1b6b:802c',
]);
mockProxyFlag(true);
expect(getRemoteAddress(MOCK_REQUEST)).to.be.equal(expectedAddress);
});
});
});

42
server/utils/logger.js Normal file
View file

@ -0,0 +1,42 @@
const winston = require('winston');
/**
* The default timestamp used by the logger.
* Format example: "2022-08-18 6:30:02"
*/
const defaultLogTimestampFormat = 'YYYY-MM-DD HH:mm:ss';
const logfile = `${process.cwd()}/logs/planka.log`;
/**
* Log level for both console and file log sinks.
*
* Refer {@link https://github.com/winstonjs/winston#logging here}
* for more information on Winston log levels.
*/
const logLevel = process.env.NODE_ENV === 'production' ? 'info' : 'debug';
const logFormat = winston.format.combine(
winston.format.uncolorize(),
winston.format.timestamp({ format: defaultLogTimestampFormat }),
winston.format.printf((log) => `${log.timestamp} [${log.level[0].toUpperCase()}] ${log.message}`),
);
// eslint-disable-next-line new-cap
const customLogger = new winston.createLogger({
transports: [
new winston.transports.File({
level: logLevel,
format: logFormat,
filename: logfile,
}),
new winston.transports.Console({
level: logLevel,
format: logFormat,
}),
],
});
module.exports = {
customLogger,
};

View file

@ -0,0 +1,27 @@
/**
* The IP address of the client that just made a request to this application, whether
* or not the TRUST_PROXY env variable is true and if endpoint accessed through a proxy.
* @param {Request} request The endpoint Request object
* @returns The IP address of the client that just made a request
*/
const getRemoteAddress = (request) => {
let remoteAddress = request.ip;
// Assert if "X-Forwarded-For" header contains any addresses
if (process.env.TRUST_PROXY && !_.isEmpty(request.ips)) {
// eslint-disable-next-line prefer-destructuring
remoteAddress = request.ips[0];
}
// Convert address from IPV6 to IPV4 if client device is IPV4.
const defaultIPV6Regex = /^::ffff:((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/g;
if (remoteAddress.match(defaultIPV6Regex)) {
remoteAddress = remoteAddress.replace('::ffff:', '');
}
return remoteAddress;
};
module.exports = {
getRemoteAddress,
};