proxies support
Massimo Melina committed
Apr 1, 2022 at 15:33 UTC
501418e6cc635887c02d1dc90007715e52669bbc
10 files changed
+93
-35
admin/src/ConfigPage.ts
+6
-1
@@ -10,6 +10,7 @@ import { subscribeKey } from 'valtio/utils'
10
import { Form, BoolField, NumberField, SelectField, FieldProps, Field } from './Form';
11
import StringStringField from './StringStringField'
12
import { alertDialog, confirmDialog } from './dialog'
13
+import { proxyWarning } from './HomePage'
14
15
let loaded: Dict | undefined
16
@@ -54,7 +55,7 @@ export default function ConfigPage() {
55
defaults({ comp }) {
56
return comp === ServerPort ? { sm: 6, lg: 3 }
57
: comp === NumberField ? { sm: 3 }
57
- : { sm: 6 }
58
+ : { sm: 6 }
59
},
60
fields: [
61
{ k: 'port', comp: ServerPort, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
@@ -70,6 +71,10 @@ export default function ConfigPage() {
71
},
72
{ k: 'accounts', label: "Accounts file" },
73
{ k: 'open_browser_at_start', comp: BoolField },
74
+ { k: 'proxies', comp: NumberField, min: 0, max: 9, sm: 6, lg: 6, label: "How many proxies between this server and users?",
75
+ error: proxyWarning(values, status),
76
+ helperText: "Wrong number will prevent detection of users' IP address"
77
+ },
78
{ k: 'allowed_referer', placeholder: "any",
79
helperText: values.allowed_referer ? "Leave empty to allow any" : "Use this to avoid direct links from other websites", },
80
{ k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 6, label: "Calculate ZIP size for seconds",
admin/src/HomePage.ts
+17
-3
@@ -1,12 +1,13 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { createElement as h, Fragment } from 'react'
4
-import { Box, Link } from '@mui/material'
5
-import { useApi } from './api'
4
+import { Box, Button, Link } from '@mui/material'
5
+import { apiCall, useApi } from './api'
6
import { Dict, dontBotherWithKeys, InLink, objSameKeys, onlyTruthy, spinner } from './misc'
7
import { CheckCircle, Error, Info, Launch, Warning } from '@mui/icons-material'
8
import md from './md'
9
import { useSnapState } from './state'
10
+import { confirmDialog } from './dialog'
11
12
interface ServerStatus { listening: boolean, port: number, error?: string, busy?: string }
13
@@ -16,7 +17,7 @@ export default function HomePage() {
17
const [status] = useApi<Dict<ServerStatus>>('get_status')
18
const [vfs] = useApi('get_vfs')
19
const [account] = useApi(username && 'get_account')
19
- const [cfg] = useApi('get_config', { only: ['https_port', 'cert', 'private_key'] })
20
+ const [cfg, reloadCfg] = useApi('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'ignore_proxies'] })
21
if (!status)
22
return spinner()
23
const { http, https } = status
@@ -48,6 +49,15 @@ export default function HomePage() {
49
),
50
!account?.adminActualAccess && entry('', "You are accessing on localhost, therefore permission is not required",
51
SOLUTION_SEP, h(InLink, { to:'accounts' }, "give admin access to an account to be able to access from other computers") ),
52
+ proxyWarning(cfg, status) && entry('warning', "A proxy was detected but none is configured",
53
+ SOLUTION_SEP, cfgLink("set the number of proxies"),
54
+ SOLUTION_SEP, "unless you are sure you can ", h(Button, {
55
+ async onClick() {
56
+ if (await confirmDialog("Go on only if you know what you are doing")
57
+ && await apiCall('set_config', { values: { ignore_proxies: true } }))
58
+ reloadCfg()
59
+ }
60
+ }, "ignore this warning")),
61
)
62
}
63
@@ -71,3 +81,7 @@ function fsLink(text=`File System page`) {
81
function cfgLink(text=`Configuration page`) {
82
return h(InLink, { to:'configuration' }, text)
83
}
84
+
85
+export function proxyWarning(cfg: any, status: any) {
86
+ return cfg && !cfg.proxies && !cfg.ignore_proxies && status?.proxyDetected
87
+}
server/src/adminApis.ts
+27
-14
@@ -13,6 +13,7 @@ import events from './events'
13
import { getFromAccount } from './perm'
14
import Koa from 'koa'
15
import { Readable } from 'stream'
16
+import { getProxyDetected } from './middlewares'
17
18
export const adminApis: ApiHandlers = {
19
@@ -44,6 +45,7 @@ export const adminApis: ApiHandlers = {
45
http: serverStatus(st.httpSrv, getConfig('port')),
46
https: serverStatus(st.httpsSrv, getConfig('https_port')),
47
urls: getUrls(),
48
+ proxyDetected: getProxyDetected(),
49
}
50
51
function serverStatus(h: typeof st.httpSrv, configuredPort?: number) {
@@ -55,8 +57,8 @@ export const adminApis: ApiHandlers = {
57
},
58
59
async disconnect({ ip, port, wait }) {
58
- const c = getConnections().find(({ socket }) =>
59
- port === socket.remotePort && ip === socket.remoteAddress )
60
+ const match = _.matches({ ip, port })
61
+ const c = getConnections().find(c => match(getConnAddress(c)))
62
const waiter = pendingPromise<void>()
63
c?.socket.end(waiter.resolve)
64
if (wait)
@@ -76,6 +78,10 @@ export const adminApis: ApiHandlers = {
78
ret.push({ remove: [ serializeConnection(conn, true) ] })
79
},
80
connectionUpdated(conn: Connection, change: Partial<Connection>) {
81
+ if (change.ctx) {
82
+ Object.assign(change, fromCtx(change.ctx))
83
+ delete change.ctx
84
+ }
85
ret.push({ update: [{ search: serializeConnection(conn, true), change }] })
86
},
87
})
@@ -84,23 +90,30 @@ export const adminApis: ApiHandlers = {
90
return ret
91
92
function serializeConnection(conn: Connection, minimal?:true) {
87
- const { socket, started, secure, got, path } = conn
88
- return {
89
- port: socket.remotePort,
90
- ip: socket.remoteAddress,
91
- ...!minimal && {
92
- v: (socket.remoteFamily?.endsWith('6') ? 6 : 4),
93
- got,
94
- started,
95
- path,
96
- secure: (secure || undefined), // undefined will save some space once json-ed
97
- }
98
- }
93
+ const { socket, started, secure, got } = conn
94
+ return Object.assign(getConnAddress(conn), !minimal && {
95
+ v: (socket.remoteFamily?.endsWith('6') ? 6 : 4),
96
+ got,
97
+ started,
98
+ secure: (secure || undefined), // undefined will save some space once json-ed
99
+ ...fromCtx(conn.ctx),
100
+ })
101
+ }
102
+
103
+ function fromCtx(ctx?: Koa.Context) {
104
+ return ctx && { path: ctx.fileSource && ctx.path } // only for downloading files
105
}
106
}
107
108
}
109
110
+function getConnAddress(conn: Connection) {
111
+ return {
112
+ ip: conn.ctx?.ip || conn.socket.remoteAddress,
113
+ port: conn.socket.remotePort,
114
+ }
115
+}
116
+
117
for (const k in adminApis) {
118
const was = adminApis[k]
119
adminApis[k] = (params, ctx) =>
server/src/connections.ts
+13
-5
@@ -2,6 +2,7 @@
2
3
import { Socket } from 'net'
4
import events from './events'
5
+import Koa from 'koa'
6
7
export interface Connection {
8
socket: Socket
@@ -10,16 +11,16 @@ export interface Connection {
11
got: number
12
sent: number
13
outSpeed?: number
13
- path?: string
14
+ ctx?: Koa.Context
15
+ alreadyEmitted: boolean // already communicated to
16
[rest:symbol]: any // let other modules add extra data, but using symbols to avoid name collision
17
}
18
19
const all: Connection[] = []
20
21
export function newConnection(socket: Socket, secure:boolean=false) {
20
- const conn: Connection = { socket, secure, got: 0, sent: 0, started: new Date() }
22
+ const conn: Connection = { socket, secure, got: 0, sent: 0, alreadyEmitted: false, started: new Date() }
23
all.push(conn)
22
- events.emit('connection', conn) // we'll use these events for SSE
24
socket.on('data', data =>
25
conn.got += data.length )
26
socket.on('close', () => {
@@ -39,7 +40,14 @@ export function socket2connection(socket: Socket) {
40
}
41
42
export function updateConnection(conn: Connection, change: Partial<Connection>) {
42
- if (Object.entries(change).every(([k,v]) => JSON.stringify(v) === JSON.stringify(conn[k as keyof Connection]) )) return // any change?
43
+ // if no change is detected, skip update. ctx is a special case
44
+ if (!change.ctx && Object.entries(change).every(([k,v]) => eq(v, conn[k as keyof Connection]) ))
45
+ return
46
Object.assign(conn, change)
44
- events.emit('connectionUpdated', conn, change)
47
+ events.emit(conn.alreadyEmitted ? 'connectionUpdated' : 'connection', conn, change)
48
+ conn.alreadyEmitted = true
49
+
50
+ function eq(a: any, b: any) {
51
+ return JSON.stringify(a) === JSON.stringify(b)
52
+ }
53
}
server/src/index.ts
+6
@@ -11,6 +11,7 @@ import { throttler } from './throttler'
11
import { headRequests, gzipper, sessions, serveGuiAndSharedFiles, someSecurity, prepareState } from './middlewares'
12
import './listen'
13
import { adminApis } from './adminApis'
14
+import { subscribeConfig } from './config'
15
16
const keys = ['hfs-keys-test']
17
export const app = new Koa({ keys })
@@ -36,3 +37,8 @@ function errorHandler(err:Error & { code:string, path:string }) {
37
process.on('uncaughtException', err => {
38
console.error(err)
39
})
40
+
41
+subscribeConfig({ k: 'proxies', defaultValue: 0 }, n => {
42
+ app.proxy = n > 0
43
+ app.maxIpsCount = n
44
+})
server/src/middlewares.ts
+14
-4
@@ -3,11 +3,11 @@
3
import compress from 'koa-compress'
4
import Koa from 'koa'
5
import session from 'koa-session'
6
-import { ADMIN_URI, BUILD_TIMESTAMP, FORBIDDEN, SESSION_DURATION } from './const'
6
+import { ADMIN_URI, BUILD_TIMESTAMP, DEV, SESSION_DURATION } from './const'
7
import Application from 'koa'
8
import { FRONTEND_URI } from './const'
9
import { cantReadStatusCode, hasPermission, urlToNode } from './vfs'
10
-import { dirTraversal, isDirectory } from './misc'
10
+import { dirTraversal, isDirectory, isLocalHost } from './misc'
11
import { zipStreamFromFolder } from './zip'
12
import { serveFileNode } from './serveFile'
13
import { serveGuiFiles } from './serveGuiFiles'
@@ -91,12 +91,18 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
91
return next()
92
}
93
94
+let proxyDetected = false
95
export const someSecurity: Koa.Middleware = async (ctx, next) => {
96
try {
97
+ let proxy = ctx.get('X-Forwarded-For')
98
+ // we have some dev-proxies to ignore
99
+ if (DEV && proxy && [process.env.FRONTEND_PROXY, process.env.ADMIN_PROXY].includes(ctx.get('X-Forwarded-port')))
100
+ proxy = ''
101
if (dirTraversal(decodeURI(ctx.path)))
102
return ctx.status = 418
103
if (applyBlock(ctx.socket))
104
return
105
+ proxyDetected ||= proxy > ''
106
}
107
catch {
108
return ctx.status = 418
@@ -104,6 +110,10 @@ export const someSecurity: Koa.Middleware = async (ctx, next) => {
110
return next()
111
}
112
113
+export function getProxyDetected() {
114
+ return proxyDetected
115
+}
116
+
117
subscribeConfig({ k: 'block', defaultValue: [] }, () => {
118
for (const { socket } of getConnections())
119
applyBlock(socket)
@@ -118,7 +128,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
128
// calculate these once and for all
129
ctx.state.account = getAccount(getCurrentUsername(ctx))
130
const conn = ctx.state.connection = socket2connection(ctx.socket)
121
- if (conn?.path) // leftover of connection reused for a new request
122
- updateConnection(conn, { path: '' })
131
+ if (conn)
132
+ updateConnection(conn, { ctx })
133
await next()
134
}
server/src/misc.ts
+1
-1
@@ -225,6 +225,6 @@ export function with_<T,RT>(par:T, cb: (par:T) => RT) {
225
226
export function isLocalHost(s: string | Koa.Context) {
227
if (typeof s !== 'string')
228
- s = s.ip
228
+ s = s.socket.remoteAddress || '' // don't use .ip as it is subject to proxied ips
229
return s === '127.0.0.1' || s === '::1' || s === '::ffff:127.0.0.1'
230
}
server/src/serveFile.ts
+1
-1
@@ -69,7 +69,7 @@ export function serveFile(source:string, mime?:string, modifier?:(s:string)=>str
69
70
const conn = ctx.state.connection
71
if (conn)
72
- updateConnection(conn, { path: ctx.path })
72
+ updateConnection(conn, { ctx }) // fileSource is affecting connection's outputted data, so we request an update
73
if (modifier)
74
return ctx.body = modifier(String(await fs.readFile(source)))
75
if (!range) {
server/src/throttler.ts
+8
-5
@@ -25,13 +25,16 @@ const SymTimeout = Symbol('timeout')
25
export const throttler: Koa.Middleware = async (ctx, next) => {
26
await next()
27
const { body } = ctx
28
- if (!body || !(body instanceof Readable) || ctx.state.account?.ignore_limits || isLocalHost(ctx))
28
+ if (!body || !(body instanceof Readable))
29
return
30
+ // we wrap the stream also for unlimited connections to get speed and other features
31
const ipGroup = getOrSet(ip2group, ctx.ip, ()=> {
31
- const tg = new ThrottleGroup(Infinity, mainThrottleGroup)
32
- const unsub = subscribeConfig({ k:'max_kbps_per_ip', defaultValue:null }, v =>
33
- tg.updateLimit(v ?? Infinity))
34
- return { group:tg, count:0, destroy: unsub }
32
+ const doLimit = ctx.state.account?.ignore_limits || isLocalHost(ctx) ? undefined : true
33
+ const group = new ThrottleGroup(Infinity, doLimit && mainThrottleGroup)
34
+
35
+ const unsub = doLimit && subscribeConfig({ k:'max_kbps_per_ip', defaultValue:null }, v =>
36
+ group.updateLimit(v ?? Infinity))
37
+ return { group, count:0, destroy: unsub }
38
})
39
const conn = ctx.state.connection
40
if (!conn) throw 'assert throttler connection'
todo.md
-1
@@ -25,7 +25,6 @@
25
- block to support masks and CIDR
26
- whitelist di ip
27
- plugin to show country by ip in admin/monitor
28
-- config.proxies:number (will enable koa.proxy:true + maxIpsCount, default 0)
28
- log filter option
29
- log filter plugin
30
- admin: improve masks editor