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 +})