admin/logs: new IPs tab
Massimo Melina committed
May 18, 2024 at 19:33 UTC
4ed4043d16b3e56051a62b93ca01747fa66b6fe0
7 files changed
+77
-7
admin/src/LogsPage.ts
+32
-3
@@ -2,7 +2,7 @@
2
3
import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useState } from 'react';
4
import { Box, Tab, Tabs } from '@mui/material'
5
-import { API_URL, useApi, useApiList } from './api'
5
+import { API_URL, apiCall, useApi, useApiList } from './api'
6
import { DataTable } from './DataTable'
7
import { CFG, Dict, formatBytes, HTTP_UNAUTHORIZED, newDialog, prefix, shortenAgent, splitAt, tryJson, md,
8
typedKeys, NBSP, _dbg, mapFilter } from '@hfs/shared'
@@ -17,13 +17,17 @@ import { ConfigForm } from './ConfigForm'
17
import { BoolField, SelectField } from '@hfs/mui-grid-form'
18
import { toast, useDialogBarColors } from './dialog'
19
import { useBlockIp } from './useBlockIp'
20
+import { ALL as COUNTRIES } from './countries'
21
22
const logLabels = {
23
log: "Access",
24
error_log: "Access error",
25
console: "Console",
26
+ ips: "IPs",
27
}
28
29
+let reloadIps: any
30
+
31
export default function LogsPage() {
32
const [tab, setTab] = useState(0)
33
const files = typedKeys(logLabels)
@@ -75,6 +79,14 @@ export default function LogsPage() {
79
{ k: CFG.log_api, sm: 6, comp: BoolField, label: "Log API requests", helperText: "Requests for commands" },
80
{ k: CFG.log_ua, sm: 6, comp: BoolField, label: "Log User-Agent", helperText: "Contains browser and possibly OS information. Can double the size of your logs on disk." },
81
{ k: CFG.log_spam, sm: 6, comp: BoolField, label: "Log spam requests", helperText: md`Spam are *failed* requests that are considered attacks aimed *not* to HFS and therefore harmless` },
82
+ { k: CFG.track_ips, sm: 6, comp: BoolField, label: "Keep track of IPs",
83
+ parentProps: { display: 'flex', gap: 1 },
84
+ after: h(Btn, {
85
+ size: 'small', variant: 'outlined', color: 'warning',
86
+ confirm: true, doneMessage: true,
87
+ onClick: () => apiCall('reset_ips').then(reloadIps)
88
+ }, "Reset")
89
+ },
90
]
91
}
92
})
@@ -120,7 +132,9 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string
132
setList(x => [...x, ...treated])
133
}
134
})
123
- const { list, setList, error, connecting } = useApiList(firstSight && 'get_log', { file }, { invert, pause, map: enhanceLogLine })
135
+ const { list, setList, error, connecting, reload } = useApiList(firstSight && 'get_log', { file }, { invert, pause, map: enhanceLogLine })
136
+ if (file === 'ips')
137
+ reloadIps = reload
138
const tsColumn: GridColDef = {
139
field: 'ts',
140
headerName: "Timestamp",
@@ -166,6 +180,21 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string
180
flex: 1,
181
mergeRender: { other: 'k', override: { valueFormatter: ({ value }) => value !== 'log' && value } }
182
}
183
+ ] : file === 'ips' ? [
184
+ tsColumn,
185
+ {
186
+ field: 'ip',
187
+ headerName: "Address",
188
+ flex: 1,
189
+ },
190
+ {
191
+ headerName: "Country",
192
+ field: 'country',
193
+ flex: 1,
194
+ hidden: !showCountry,
195
+ valueGetter: ({ value }) => _.find(COUNTRIES, { code: value })?.name || value,
196
+ renderCell: ({ row }) => h(Country, { code: row.country, long: true, def: '-' }),
197
+ }
198
] : [
199
{
200
field: 'ip',
@@ -259,7 +288,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string
288
function enhanceLogLine(x: any) {
289
if (!x) return
290
const { extra } = x
262
- if (extra?.country && !showCountry)
291
+ if ((extra?.country || x.country) && !showCountry)
292
setShowCountry(true)
293
if (extra?.ua && !showAgent)
294
setShowAgent(true)
mui-grid-form/index.ts
+3
-2
@@ -26,6 +26,7 @@ export interface FieldDescriptor<T=any> extends FieldApi<T> {
26
before?: ReactNode
27
after?: ReactNode
28
getError?: GetError
29
+ parentProps?: Partial<GridProps>,
30
[extraProp: string]: any
31
}
32
@@ -163,12 +164,12 @@ export function Form<Values extends Dict>({
164
_.defaults(field, defaults?.(whole))
165
}
166
{
166
- const { xs=12, sm, md, lg, xl, comp=StringField, before, after,
167
+ const { xs=12, sm, md, lg, xl, comp=StringField, before, after, parentProps,
168
fromField, toField, // don't propagate
169
...rest } = field
170
Object.assign(rest, { name: k })
171
const n = (keyMet[k] = (keyMet[k] || 0) + 1)
171
- return h(Grid, { key: k ? k + n : idx, item: true, xs, sm, md, lg, xl },
172
+ return h(Grid, { key: k ? k + n : idx, item: true, xs, sm, md, lg, xl, ...parentProps },
173
before,
174
isValidElement(comp) ? comp : h(comp, rest),
175
after
package.json
+1
-1
@@ -71,7 +71,7 @@
71
"dependencies": {
72
"@koa/router": "^12.0.1",
73
"@node-rs/crc32": "^1.6.0",
74
- "@rejetto/kvstorage": "^0.4.0",
74
+ "@rejetto/kvstorage": "^0.9.3",
75
"acme-client": "^5.2.0",
76
"basic-auth": "^2.0.1",
77
"buffer-crc32": "^0.2.13",
src/api.log.ts
+12
@@ -6,6 +6,7 @@ import events from './events'
6
import { loggers } from './log'
7
import { SendListReadable } from './SendList'
8
import { serveFile } from './serveFile'
9
+import { ips } from './ip'
10
11
export default {
12
async get_log_file({ file = 'log', range = '' }, ctx) {
@@ -27,6 +28,12 @@ export default {
28
return new SendListReadable({
29
bufferTime: 10,
30
async doAtStart(list) {
31
+ if (file === 'ips') {
32
+ for await (const [k, v] of ips.iterator())
33
+ list.add({ ip: k, ...v })
34
+ list.ready()
35
+ return
36
+ }
37
if (file === 'console') {
38
for (const chunk of _.chunk(consoleLog, 1000)) { // avoid occupying the thread too long
39
for (const x of chunk)
@@ -46,4 +53,9 @@ export default {
53
})
54
55
},
56
+
57
+ reset_ips() {
58
+ return ips.clear()
59
+ },
60
+
61
} satisfies ApiHandlers
\ No newline at end of file
src/cross.ts
+1
-1
@@ -24,7 +24,7 @@ export const FRONTEND_OPTIONS = {
24
export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time']
25
export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
26
export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
27
- 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam',
27
+ 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
28
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address'])
29
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
30
export type Dict<T=any> = Record<string, T>
src/index.ts
+2
@@ -25,6 +25,7 @@ import './geo'
25
import { geoFilter } from './geo'
26
import { rootsMiddleware } from './roots'
27
import events from './events'
28
+import { trackIpsMw } from './ip'
29
30
ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
31
@@ -38,6 +39,7 @@ app.use(sessionMiddleware)
39
.use(someSecurity)
40
.use(prepareState)
41
.use(geoFilter)
42
+ .use(trackIpsMw)
43
.use(gzipper)
44
.use(paramsDecoder) // must be done before plugins, so they can manipulate params
45
.use(headRequests)
src/ip.ts
new
+26
@@ -0,0 +1,26 @@
1
+// This file is part of HFS - Copyright 2021, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+import { defineConfig } from './config'
4
+import { KvStorage } from '@rejetto/kvstorage'
5
+import { Middleware } from 'koa'
6
+import { CFG, isLocalHost, MINUTE } from './misc'
7
+
8
+const trackIps = defineConfig(CFG.track_ips, true)
9
+export const ips = new KvStorage({
10
+ defaultPutDelay: MINUTE,
11
+ maxPutDelay: 10 * MINUTE,
12
+ maxPutDelayCreate: 0,
13
+})
14
+
15
+export const trackIpsMw: Middleware = async (ctx, next) => {
16
+ if (trackIps.get() && !isLocalHost(ctx))
17
+ ips.put(ctx.ip, { ts: new Date, country: ctx.state.connection.country })
18
+ await next()
19
+}
20
+
21
+trackIps.sub(v => {
22
+ if (v)
23
+ ips.open('ips.kv')
24
+ else
25
+ ips.close()
26
+})