mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
feat: Modify logger to log to file that supports fail2ban (#284)
This commit is contained in:
parent
5a27ac0f03
commit
fe5fe5fab7
12 changed files with 427 additions and 7844 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,5 +1,7 @@
|
||||||
node_modules
|
node_modules
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
/logs
|
||||||
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
73
README.md
73
README.md
|
@ -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 installation’s `/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 installation’s `/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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
4
server/config/env/production.js
vendored
4
server/config/env/production.js
vendored
|
@ -243,9 +243,7 @@ module.exports = {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
log: {
|
log: {},
|
||||||
level: 'debug',
|
|
||||||
},
|
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
/**
|
/**
|
||||||
|
|
4
server/config/env/test.js
vendored
4
server/config/env/test.js
vendored
|
@ -64,7 +64,5 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
log: {
|
log: {},
|
||||||
level: 'warn',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
7992
server/package-lock.json
generated
7992
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||||
|
|
78
server/test/utils/remoteAddress.test.js
Normal file
78
server/test/utils/remoteAddress.test.js
Normal 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
42
server/utils/logger.js
Normal 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,
|
||||||
|
};
|
27
server/utils/remoteAddress.js
Normal file
27
server/utils/remoteAddress.js
Normal 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,
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue