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