@hyperwatch/hyperwatch 3.9.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +5 -4
- package/scripts/cloudfront-ips.js +56 -0
- package/src/input/vercel.js +81 -0
- package/src/lib/cache.js +14 -6
- package/src/modules/identity.js +4 -4
- package/src/plugins/proxy.js +164 -13
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Hyperwatch is built on a real-time stream processor handling logs from inputs of
|
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
|
-
Make sure you have Node.js version >=
|
|
15
|
+
Make sure you have Node.js version >= 14.
|
|
16
16
|
|
|
17
17
|
We recommend using [nvm](https://github.com/creationix/nvm): `nvm install && nvm use`.
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperwatch/hyperwatch",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Open Source HTTP Traffic Manager",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "François Hodierne <francois@hodierne.net>",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"immutable": "^4.1.0",
|
|
42
42
|
"ip-cidr": "^3.0.10",
|
|
43
43
|
"lodash": "^4.17.21",
|
|
44
|
-
"lru-cache": "^
|
|
44
|
+
"lru-cache": "^10.0.1",
|
|
45
45
|
"micro-strptime": "^0.2.3",
|
|
46
46
|
"proxy-addr": "^2.0.7",
|
|
47
47
|
"rc": "^1.2.8",
|
|
@@ -58,11 +58,12 @@
|
|
|
58
58
|
"husky": "^4.3.5",
|
|
59
59
|
"lint-staged": "^10.5.3",
|
|
60
60
|
"mocha": "^10.0.0",
|
|
61
|
-
"
|
|
61
|
+
"node-fetch": "^2.0.0",
|
|
62
|
+
"prettier": "^3.0.3",
|
|
62
63
|
"prettier-package-json": "^2.7.0"
|
|
63
64
|
},
|
|
64
65
|
"engines": {
|
|
65
|
-
"node": ">=
|
|
66
|
+
"node": ">=14.0"
|
|
66
67
|
},
|
|
67
68
|
"depcheck": {
|
|
68
69
|
"ignores": [
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable-next-line node/no-unpublished-require */
|
|
2
|
+
const fetch = require('node-fetch');
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const response = await fetch(
|
|
6
|
+
'https://ip-ranges.amazonaws.com/ip-ranges.json'
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
const ipRanges = await response.json();
|
|
10
|
+
|
|
11
|
+
console.log(
|
|
12
|
+
ipRanges.prefixes
|
|
13
|
+
.filter((p) => p.service === 'CLOUDFRONT')
|
|
14
|
+
.map((el) => el.ip_prefix)
|
|
15
|
+
.sort((a, b) => {
|
|
16
|
+
const aArray = a.split('.').map(Number);
|
|
17
|
+
const bArray = b.split('.').map(Number);
|
|
18
|
+
if (aArray[0] === bArray[0]) {
|
|
19
|
+
if (aArray[1] === bArray[1]) {
|
|
20
|
+
if (aArray[2] === bArray[2]) {
|
|
21
|
+
return aArray[3] > bArray[3] ? 1 : -1;
|
|
22
|
+
}
|
|
23
|
+
return aArray[2] > bArray[2] ? 1 : -1;
|
|
24
|
+
}
|
|
25
|
+
return aArray[1] > bArray[1] ? 1 : -1;
|
|
26
|
+
}
|
|
27
|
+
return aArray[0] > bArray[0] ? 1 : -1;
|
|
28
|
+
})
|
|
29
|
+
.map((el) => `"${el}"`)
|
|
30
|
+
.join(',\n')
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
console.log(
|
|
34
|
+
ipRanges.ipv6_prefixes
|
|
35
|
+
.filter((p) => p.service === 'CLOUDFRONT')
|
|
36
|
+
.map((el) => el.ipv6_prefix)
|
|
37
|
+
.sort((a, b) => {
|
|
38
|
+
const aArray = a.split(':').map(Number);
|
|
39
|
+
const bArray = b.split(':').map(Number);
|
|
40
|
+
if (aArray[0] === bArray[0]) {
|
|
41
|
+
if (aArray[1] === bArray[1]) {
|
|
42
|
+
if (aArray[2] === bArray[2]) {
|
|
43
|
+
return aArray[3] > bArray[3] ? 1 : -1;
|
|
44
|
+
}
|
|
45
|
+
return aArray[2] > bArray[2] ? 1 : -1;
|
|
46
|
+
}
|
|
47
|
+
return aArray[1] > bArray[1] ? 1 : -1;
|
|
48
|
+
}
|
|
49
|
+
return aArray[0] > bArray[0] ? 1 : -1;
|
|
50
|
+
})
|
|
51
|
+
.map((el) => `"${el}"`)
|
|
52
|
+
.join(',\n')
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const { fromJS } = require('immutable');
|
|
4
|
+
|
|
5
|
+
const app = require('../app/api');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a log from Vercel JSON log drain format
|
|
9
|
+
*/
|
|
10
|
+
function parseVercel(request) {
|
|
11
|
+
return fromJS({
|
|
12
|
+
request: {
|
|
13
|
+
time: new Date(request.proxy.timestamp).toISOString(),
|
|
14
|
+
address: request.proxy.clientIp,
|
|
15
|
+
scheme: request.proxy.scheme,
|
|
16
|
+
method: request.proxy.method,
|
|
17
|
+
url: request.proxy.path,
|
|
18
|
+
headers: {
|
|
19
|
+
host: request.proxy.host,
|
|
20
|
+
'user-agent': request.proxy.userAgent[0],
|
|
21
|
+
},
|
|
22
|
+
captured_headers: ['host', 'user-agent'],
|
|
23
|
+
},
|
|
24
|
+
response: {
|
|
25
|
+
status: request.proxy.statusCode,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function verifySignature(req, secret) {
|
|
31
|
+
if (!req.headers['x-vercel-signature'] || !secret) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
req.headers['x-vercel-signature'] ===
|
|
37
|
+
crypto.createHmac('sha1', secret).update(req.rawBody).digest('hex')
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function create({
|
|
42
|
+
name = 'HTTP input (Vercel JSON log drain format)',
|
|
43
|
+
path = '/input/vercel',
|
|
44
|
+
parse = parseVercel,
|
|
45
|
+
verify,
|
|
46
|
+
secret,
|
|
47
|
+
}) {
|
|
48
|
+
return {
|
|
49
|
+
name: name,
|
|
50
|
+
start: ({ success, reject, status }) => {
|
|
51
|
+
app.post(path, (req, res) => {
|
|
52
|
+
// Send back verify header (only needed to setup the drain)
|
|
53
|
+
if (verify) {
|
|
54
|
+
res.set('x-vercel-verify', verify);
|
|
55
|
+
}
|
|
56
|
+
// Verify signature
|
|
57
|
+
if (!verifySignature(req, secret)) {
|
|
58
|
+
res.statusCode = 403;
|
|
59
|
+
res.send("Signature didn't match");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// No extra validation before sending the response to the client
|
|
63
|
+
res.send('Ok');
|
|
64
|
+
// Processing the message(s)
|
|
65
|
+
const messages = Array.isArray(req.body) ? req.body : [req.body];
|
|
66
|
+
messages.forEach((message) => {
|
|
67
|
+
try {
|
|
68
|
+
success(parse(message));
|
|
69
|
+
} catch (err) {
|
|
70
|
+
reject(err);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
status(null, `Listening on http://__HOST__${path}`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
create: create,
|
|
81
|
+
};
|
package/src/lib/cache.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
const
|
|
1
|
+
const { LRUCache } = require('lru-cache');
|
|
2
2
|
|
|
3
3
|
const lruProvider = (options) => {
|
|
4
|
-
const lruInstance = new
|
|
4
|
+
const lruInstance = new LRUCache(options);
|
|
5
5
|
return {
|
|
6
|
-
clear: async () => lruInstance.
|
|
7
|
-
del: async (key) => lruInstance.
|
|
6
|
+
clear: async () => lruInstance.clear(),
|
|
7
|
+
del: async (key) => lruInstance.delete(key),
|
|
8
8
|
get: async (key) => lruInstance.get(key),
|
|
9
9
|
has: async (key) => lruInstance.has(key),
|
|
10
10
|
set: async (key, value, ttlInSeconds) =>
|
|
11
|
-
lruInstance.set(key, value,
|
|
11
|
+
lruInstance.set(key, value, { ttl: ttlInSeconds * 1000 }),
|
|
12
12
|
};
|
|
13
13
|
};
|
|
14
14
|
|
|
@@ -30,4 +30,12 @@ async function set(key, value, ttlInSeconds) {
|
|
|
30
30
|
return cacheProvider.set(key, value, ttlInSeconds);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
async function del(key) {
|
|
34
|
+
return cacheProvider.del(key);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function clear(key) {
|
|
38
|
+
return cacheProvider.clear(key);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { has, get, set, del, clear, setProvider };
|
package/src/modules/identity.js
CHANGED
|
@@ -195,6 +195,10 @@ function augment(log) {
|
|
|
195
195
|
return hostname && hostname.endsWith('.infotiger.com')
|
|
196
196
|
? log.set('identity', 'InfoTiger')
|
|
197
197
|
: log;
|
|
198
|
+
case 'Amazonbot':
|
|
199
|
+
return hostname && hostname.endsWith('.crawl.amazonbot.amazon')
|
|
200
|
+
? log.set('identity', 'Amazonbot')
|
|
201
|
+
: log;
|
|
198
202
|
|
|
199
203
|
// Per hostname + CIDR
|
|
200
204
|
case 'Twitterbot':
|
|
@@ -262,10 +266,6 @@ function augment(log) {
|
|
|
262
266
|
hostname.endsWith('.eu-central-1.compute.amazonaws.com')
|
|
263
267
|
? log.set('identity', 'Wise')
|
|
264
268
|
: log;
|
|
265
|
-
case 'Amazonbot':
|
|
266
|
-
return hostname && hostname.endsWith('.compute-1.amazonaws.com')
|
|
267
|
-
? log.set('identity', 'Amazonbot')
|
|
268
|
-
: log;
|
|
269
269
|
|
|
270
270
|
// GCE
|
|
271
271
|
case 'VelenPublicWebCrawler':
|
package/src/plugins/proxy.js
CHANGED
|
@@ -3,52 +3,169 @@ const proxyaddr = require('proxy-addr');
|
|
|
3
3
|
|
|
4
4
|
const ipRanges = {
|
|
5
5
|
cloudflare: [
|
|
6
|
+
// Source: https://www.cloudflare.com/ips/
|
|
7
|
+
'173.245.48.0/20',
|
|
6
8
|
'103.21.244.0/22',
|
|
7
9
|
'103.22.200.0/22',
|
|
8
10
|
'103.31.4.0/22',
|
|
9
|
-
'104.16.0.0/12',
|
|
10
|
-
'108.162.192.0/18',
|
|
11
|
-
'131.0.72.0/22',
|
|
12
11
|
'141.101.64.0/18',
|
|
13
|
-
'162.
|
|
14
|
-
'172.64.0.0/13',
|
|
15
|
-
'173.245.48.0/20',
|
|
16
|
-
'188.114.96.0/20',
|
|
12
|
+
'108.162.192.0/18',
|
|
17
13
|
'190.93.240.0/20',
|
|
14
|
+
'188.114.96.0/20',
|
|
18
15
|
'197.234.240.0/22',
|
|
19
16
|
'198.41.128.0/17',
|
|
20
|
-
'
|
|
17
|
+
'162.158.0.0/15',
|
|
18
|
+
'104.16.0.0/13',
|
|
19
|
+
'104.24.0.0/14',
|
|
20
|
+
'172.64.0.0/13',
|
|
21
|
+
'131.0.72.0/22',
|
|
21
22
|
'2400:cb00::/32',
|
|
22
|
-
'2405:8100::/32',
|
|
23
|
-
'2405:b500::/32',
|
|
24
23
|
'2606:4700::/32',
|
|
25
24
|
'2803:f800::/32',
|
|
25
|
+
'2405:b500::/32',
|
|
26
|
+
'2405:8100::/32',
|
|
27
|
+
'2a06:98c0::/29',
|
|
28
|
+
'2c0f:f248::/32',
|
|
26
29
|
],
|
|
27
30
|
cloudfront: [
|
|
31
|
+
// Source: scripts/cloudfront-ips.js
|
|
32
|
+
'3.10.17.128/25',
|
|
33
|
+
'3.11.53.0/24',
|
|
34
|
+
'3.35.130.128/25',
|
|
35
|
+
'3.101.158.0/23',
|
|
36
|
+
'3.128.93.0/24',
|
|
37
|
+
'3.134.215.0/24',
|
|
38
|
+
'3.160.0.0/14',
|
|
39
|
+
'3.231.2.0/25',
|
|
40
|
+
'3.234.232.224/27',
|
|
41
|
+
'3.236.48.0/23',
|
|
42
|
+
'3.236.169.192/26',
|
|
28
43
|
'13.32.0.0/15',
|
|
44
|
+
'13.35.0.0/16',
|
|
45
|
+
'13.48.32.0/24',
|
|
29
46
|
'13.54.63.128/26',
|
|
47
|
+
'13.59.250.0/26',
|
|
48
|
+
'13.113.196.64/26',
|
|
49
|
+
'13.113.203.0/24',
|
|
50
|
+
'13.124.199.0/24',
|
|
51
|
+
'13.210.67.128/26',
|
|
52
|
+
'13.224.0.0/14',
|
|
53
|
+
'13.228.69.0/24',
|
|
54
|
+
'13.233.177.192/26',
|
|
55
|
+
'13.249.0.0/16',
|
|
56
|
+
'15.158.0.0/16',
|
|
57
|
+
'15.188.184.0/24',
|
|
58
|
+
'15.207.13.128/25',
|
|
59
|
+
'15.207.213.128/25',
|
|
60
|
+
'18.64.0.0/14',
|
|
61
|
+
'18.68.0.0/16',
|
|
62
|
+
'18.154.0.0/15',
|
|
63
|
+
'18.160.0.0/15',
|
|
64
|
+
'18.164.0.0/15',
|
|
65
|
+
'18.172.0.0/15',
|
|
66
|
+
'18.192.142.0/23',
|
|
67
|
+
'18.200.212.0/23',
|
|
68
|
+
'18.216.170.128/25',
|
|
69
|
+
'18.229.220.192/26',
|
|
70
|
+
'18.238.0.0/15',
|
|
71
|
+
'18.244.0.0/15',
|
|
30
72
|
'34.195.252.0/24',
|
|
73
|
+
'34.216.51.0/25',
|
|
74
|
+
'34.223.12.224/27',
|
|
75
|
+
'34.223.80.192/26',
|
|
76
|
+
'34.226.14.0/24',
|
|
77
|
+
'35.158.136.0/24',
|
|
31
78
|
'35.162.63.192/26',
|
|
32
79
|
'35.167.191.128/26',
|
|
80
|
+
'36.103.232.0/25',
|
|
81
|
+
'36.103.232.128/26',
|
|
82
|
+
'44.227.178.0/24',
|
|
83
|
+
'44.234.90.252/30',
|
|
84
|
+
'44.234.108.128/25',
|
|
33
85
|
'52.15.127.128/26',
|
|
34
86
|
'52.46.0.0/18',
|
|
87
|
+
'52.47.139.0/24',
|
|
35
88
|
'52.52.191.128/26',
|
|
36
89
|
'52.56.127.0/25',
|
|
37
90
|
'52.57.254.0/24',
|
|
38
91
|
'52.66.194.128/26',
|
|
39
92
|
'52.78.247.128/26',
|
|
93
|
+
'52.82.128.0/19',
|
|
40
94
|
'52.84.0.0/15',
|
|
95
|
+
'52.124.128.0/17',
|
|
41
96
|
'52.199.127.192/26',
|
|
42
97
|
'52.212.248.0/26',
|
|
43
98
|
'52.220.191.0/26',
|
|
44
99
|
'52.222.128.0/17',
|
|
45
100
|
'54.182.0.0/16',
|
|
46
101
|
'54.192.0.0/16',
|
|
47
|
-
'54.230.0.0/
|
|
102
|
+
'54.230.0.0/17',
|
|
103
|
+
'54.230.128.0/18',
|
|
104
|
+
'54.230.200.0/21',
|
|
105
|
+
'54.230.208.0/20',
|
|
106
|
+
'54.230.224.0/19',
|
|
48
107
|
'54.233.255.128/26',
|
|
49
108
|
'54.239.128.0/18',
|
|
50
109
|
'54.239.192.0/19',
|
|
51
110
|
'54.240.128.0/18',
|
|
111
|
+
'58.254.138.128/26',
|
|
112
|
+
'58.254.138.0/25',
|
|
113
|
+
'64.252.64.0/18',
|
|
114
|
+
'64.252.128.0/18',
|
|
115
|
+
'65.8.0.0/16',
|
|
116
|
+
'65.9.0.0/17',
|
|
117
|
+
'65.9.128.0/18',
|
|
118
|
+
'70.132.0.0/18',
|
|
119
|
+
'71.152.0.0/17',
|
|
120
|
+
'99.79.169.0/24',
|
|
121
|
+
'99.84.0.0/16',
|
|
122
|
+
'99.86.0.0/16',
|
|
123
|
+
'108.138.0.0/15',
|
|
124
|
+
'108.156.0.0/14',
|
|
125
|
+
'111.13.171.192/26',
|
|
126
|
+
'111.13.171.128/26',
|
|
127
|
+
'111.13.185.64/27',
|
|
128
|
+
'111.13.185.32/27',
|
|
129
|
+
'116.129.226.0/25',
|
|
130
|
+
'116.129.226.128/26',
|
|
131
|
+
'118.193.97.128/25',
|
|
132
|
+
'118.193.97.64/26',
|
|
133
|
+
'119.147.182.128/26',
|
|
134
|
+
'119.147.182.0/25',
|
|
135
|
+
'120.52.12.64/26',
|
|
136
|
+
'120.52.22.96/27',
|
|
137
|
+
'120.52.39.128/27',
|
|
138
|
+
'120.52.153.192/26',
|
|
139
|
+
'120.232.236.128/26',
|
|
140
|
+
'120.232.236.0/25',
|
|
141
|
+
'120.253.240.192/26',
|
|
142
|
+
'120.253.241.160/27',
|
|
143
|
+
'120.253.245.192/27',
|
|
144
|
+
'120.253.245.128/26',
|
|
145
|
+
'130.176.0.0/17',
|
|
146
|
+
'130.176.128.0/18',
|
|
147
|
+
'130.176.192.0/19',
|
|
148
|
+
'130.176.224.0/20',
|
|
149
|
+
'143.204.0.0/16',
|
|
150
|
+
'144.220.0.0/16',
|
|
151
|
+
'180.163.57.0/25',
|
|
152
|
+
'180.163.57.128/26',
|
|
153
|
+
'204.246.164.0/22',
|
|
154
|
+
'204.246.168.0/22',
|
|
155
|
+
'204.246.172.0/24',
|
|
156
|
+
'204.246.173.0/24',
|
|
157
|
+
'204.246.174.0/23',
|
|
158
|
+
'204.246.176.0/20',
|
|
159
|
+
'205.251.200.0/21',
|
|
160
|
+
'205.251.208.0/20',
|
|
161
|
+
'205.251.249.0/24',
|
|
162
|
+
'205.251.250.0/23',
|
|
163
|
+
'205.251.252.0/23',
|
|
164
|
+
'205.251.254.0/24',
|
|
165
|
+
'216.137.32.0/19',
|
|
166
|
+
'223.71.11.0/27',
|
|
167
|
+
'223.71.71.96/27',
|
|
168
|
+
'223.71.71.128/25',
|
|
52
169
|
'204.246.164.0/22',
|
|
53
170
|
'204.246.168.0/22',
|
|
54
171
|
'204.246.174.0/23',
|
|
@@ -59,9 +176,43 @@ const ipRanges = {
|
|
|
59
176
|
'205.251.252.0/23',
|
|
60
177
|
'205.251.254.0/24',
|
|
61
178
|
'216.137.32.0/19',
|
|
179
|
+
'2400:7fc0:500::/40',
|
|
180
|
+
'2404:c2c0:500::/40',
|
|
181
|
+
'2409:8c00:2421:400::/56',
|
|
182
|
+
'2409:8c00:2421:300::/56',
|
|
183
|
+
'2600:9000:eee::/48',
|
|
184
|
+
'2600:9000:f580::/41',
|
|
185
|
+
'2600:9000:5310::/44',
|
|
186
|
+
'2600:9000:f520::/44',
|
|
187
|
+
'2600:9000:f534::/46',
|
|
188
|
+
'2600:9000:5308::/45',
|
|
189
|
+
'2600:9000:fff::/48',
|
|
190
|
+
'2600:9000:1000::/36',
|
|
191
|
+
'2600:9000:2000::/36',
|
|
192
|
+
'2600:9000:4000::/36',
|
|
193
|
+
'2600:9000:5320::/43',
|
|
194
|
+
'2600:9000:5340::/42',
|
|
195
|
+
'2600:9000:5380::/41',
|
|
196
|
+
'2600:9000:f538::/45',
|
|
197
|
+
'2600:9000:f400::/40',
|
|
198
|
+
'2600:9000:f800::/37',
|
|
199
|
+
'2600:9000:ddd::/48',
|
|
200
|
+
'2600:9000:f500::/43',
|
|
201
|
+
'2600:9000:f000::/38',
|
|
202
|
+
'2600:9000:f540::/42',
|
|
203
|
+
'2600:9000:f600::/39',
|
|
204
|
+
'2600:9000:3000::/36',
|
|
205
|
+
],
|
|
206
|
+
sucuri: [
|
|
207
|
+
// Source: https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/
|
|
208
|
+
'192.88.134.0/23',
|
|
209
|
+
'185.93.228.0/22',
|
|
210
|
+
'66.248.200.0/22',
|
|
211
|
+
'2a02:fe80::/29',
|
|
212
|
+
'208.109.0.0/22',
|
|
62
213
|
],
|
|
63
|
-
|
|
64
|
-
|
|
214
|
+
imperva: [
|
|
215
|
+
// Source: https://docs.imperva.com/howto/c85245b7
|
|
65
216
|
'199.83.128.0/21',
|
|
66
217
|
'198.143.32.0/19',
|
|
67
218
|
'149.126.72.0/21',
|