fix: outbound_proxy not working #1068
Massimo Melina committed
Aug 26, 2025 at 11:12 UTC
e132c5e8811786263ed4b3727a4420a24033b414
6 files changed
+55
-14
admin/src/OptionsPage.ts
+1
-1
@@ -159,7 +159,7 @@ export default function OptionsPage() {
159
error: proxyWarning(values, status),
160
helperText: "Wrong number will prevent detection of users' IP"
161
},
162
- { k: 'outbound_proxy', xs: 12, sm: 5, md: 4, placeholder: "none", helperText: "URL form",
162
+ { k: CFG.outbound_proxy, xs: 12, sm: 5, md: 4, placeholder: "none", helperText: "URL form",
163
getError: x => try_(() => x && new URL(x) && '', () => "Invalid URL") },
164
{ k: 'allowed_referer', comp: AllowedReferer, sm: 3, md: 4, placeholder: "any", label: "Links from other websites",
165
helperText: "In case another website is linking your files" },
config.md
+2
-1
@@ -121,7 +121,8 @@ Configuration can be done in several ways
121
- `dynamic_dns_url` URL to be requested to keep a domain updated with your latest IP address.
122
Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used.
123
Multiple URLs are supported, and you can specify one for each line.
124
-- `outbound_proxy` if you need outgoing http(s) requests to pass through an HTTP proxy. Default is none.
124
+- `outbound_proxy` if you need outgoing http(s) requests to pass through an HTTP proxy. E.g.: `http://user:password@localhost:8888`. Default is none.
125
+ Setting one will trigger a test request to google.com. You can skip this with env HFS_SKIP_PROXY_TEST=1 .
126
- `auto_basic` automatically detect (based on user-agent) when the basic web interface should be served, to support legacy browsers. Default is true. No UI.
127
- `authorization_header` support Authentication HTTP header. Default is true. No UI.
128
- `cache_control_disk_files` number of seconds after which the browser should bypass the cache and check the server for an updated version of the file. Default is 5. No UI.
src/cross.ts
+2
-1
@@ -31,7 +31,8 @@ export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
31
export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
32
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
33
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
34
- 'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html', 'comments_storage'])
34
+ 'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html', 'comments_storage',
35
+ 'outbound_proxy'])
36
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
37
export type Dict<T=any> = Record<string, T>
38
export type Falsy = false | null | undefined | '' | 0
src/index.ts
+1
@@ -26,6 +26,7 @@ import { geoFilter } from './geo'
26
import { rootsMiddleware } from './roots'
27
import events from './events'
28
import { trackIpsMw } from './ips'
29
+import './outboundProxy'
30
31
ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
32
src/outboundProxy.ts
+8
-6
@@ -1,16 +1,19 @@
1
import { defineConfig } from './config'
2
import { parse } from 'node:url'
3
-import { httpStream } from './util-http'
3
+import { httpStream, httpString } from './util-http'
4
import { reg } from './util-os'
5
import events from './events'
6
import { IS_WINDOWS } from './const'
7
-import { prefix } from './cross'
7
+import { CFG, prefix } from './cross'
8
9
-// don't move this in util-http, where it would mostly belong, as a require to config.ts would prevent tests using util-http
10
-const outboundProxy = defineConfig('outbound_proxy', '', v => {
9
+const outboundProxy = defineConfig(CFG.outbound_proxy, '', v => {
10
try {
12
- parse(v)
11
+ parse(v) // just validate
12
httpStream.defaultProxy = v
13
+ if (!v || process.env.HFS_SKIP_PROXY_TEST) return
14
+ const test = 'https://google.com'
15
+ httpString(test).catch(e =>
16
+ console.error(`proxy failed for ${test} : ${e?.errors?.[0] || e}`)) // `.errors` in case of AggregateError
17
}
18
catch {
19
console.warn("invalid URL", v)
@@ -18,7 +21,6 @@ const outboundProxy = defineConfig('outbound_proxy', '', v => {
21
}
22
})
23
21
-
24
events.once('configReady', async startedWithoutConfig => {
25
if (!IS_WINDOWS || !startedWithoutConfig) return
26
// try to read Windows system setting for proxy
src/util-http.ts
+41
-5
@@ -6,6 +6,7 @@ import http, { IncomingMessage } from 'node:http'
6
import { Readable } from 'node:stream'
7
import _ from 'lodash'
8
import { text as stream2string, buffer } from 'node:stream/consumers'
9
+import * as tls from 'node:tls'
10
export { stream2string }
11
12
export async function httpString(url: string, options?: XRequestOptions): Promise<string> {
@@ -23,7 +24,7 @@ export async function httpWithBody(url: string, options?: XRequestOptions): Prom
24
export interface XRequestOptions extends https.RequestOptions {
25
body?: string | Buffer | Readable
26
proxy?: string // url format
26
- // basic cookie store
27
+ // very basic cookie store
28
jar?: Record<string, string>
29
noRedirect?: boolean
30
// throw for http-level errors. Default is true.
@@ -34,7 +35,7 @@ export declare namespace httpStream { let defaultProxy: string | undefined }
35
export function httpStream(url: string, { body, jar, noRedirect, httpThrow, proxy, ...options }: XRequestOptions ={}) {
36
const controller = new AbortController()
37
options.signal ??= controller.signal
37
- return Object.assign(new Promise<IncomingMessage>((resolve, reject) => {
38
+ return Object.assign(new Promise<IncomingMessage>(async (resolve, reject) => {
39
proxy ??= httpStream.defaultProxy
40
options.headers ??= {}
41
if (body) {
@@ -49,11 +50,25 @@ export function httpStream(url: string, { body, jar, noRedirect, httpThrow, prox
50
if (jar)
51
options.headers.cookie = _.map(jar, (v,k) => `${k}=${v}; `).join('')
52
+ (options.headers.cookie || '') // preserve parameter
52
- Object.assign(options, _.pick(parse(proxy || url), ['hostname', 'port', 'path', 'protocol', 'auth']))
53
+ const { auth, ...parsed } = parse(url)
54
+ const proxyParsed = proxy ? parse(proxy) : null
55
+ Object.assign(options, _.pick(proxyParsed || parsed, ['hostname', 'port', 'path', 'protocol']))
56
+ if (auth) {
57
+ options.auth = auth
58
+ if (proxy)
59
+ url = parsed.protocol + '//' + parsed.host + parsed.path // rewrite without authentication part
60
+ }
61
if (proxy) {
62
options.path = url
63
options.headers.host ??= parse(url).host || undefined
64
}
65
+ // this needs the prefix "proxy-"
66
+ const proxyAuth = proxyParsed?.auth ? { 'proxy-authorization': `Basic ${Buffer.from(proxyParsed.auth, 'utf8').toString('base64')}` } : undefined
67
+
68
+ // https through proxy is better with CONNECT
69
+ if (!proxy || parsed.protocol === 'http:' || !await connect())
70
+ Object.assign(options.headers, proxyAuth)
71
+
72
const proto = options.protocol === 'https:' ? https : http
73
const req = proto.request(options, res => {
74
console.debug("http responded", res.statusCode, "to", url)
@@ -67,12 +82,14 @@ export function httpStream(url: string, { body, jar, noRedirect, httpThrow, prox
82
return reject(new Error(String(res.statusCode), { cause: res }))
83
let r = res.headers.location
84
if (r && !noRedirect) {
70
- if (r.startsWith('/')) // relative
71
- r = /(.+)\b\/(\b|$)/.exec(url)?.[1] + r
85
+ r = new URL(r, url).toString() // rewrite in case r is just a path, and thus relative to current url
86
const stack = ((options as any)._stack ||= [])
87
if (stack.length > 20 || stack.includes(r))
88
return reject(new Error('endless http redirection'))
89
stack.push(r)
90
+ delete options.method // redirections are always GET
91
+ delete options.headers?.['content-length']
92
+ delete options.auth
93
return resolve(httpStream(r, options))
94
}
95
resolve(res)
@@ -83,6 +100,25 @@ export function httpStream(url: string, { body, jar, noRedirect, httpThrow, prox
100
body.pipe(req).on('end', () => req.end())
101
else
102
req.end(body)
103
+
104
+ function connect() {
105
+ return proxyParsed && new Promise<boolean>(resolve => {
106
+ (proxyParsed.protocol === 'https:' ? https : http).request({
107
+ ...proxyParsed,
108
+ method: 'CONNECT',
109
+ path: `${parsed.hostname}:${parsed.port || 443}`,
110
+ auth: undefined,
111
+ headers: proxyAuth
112
+ }).on('error', reject).on('connect', (res, socket) => {
113
+ if (res.statusCode !== 200)
114
+ return resolve(false)
115
+ // a TLS for every request is inefficient. Consider optimizing in the future, especially for reading plugins from github, which makes tens of requests.
116
+ options.createConnection = () => tls.connect({ socket, servername: parsed.hostname || undefined })
117
+ resolve(true)
118
+ }).end()
119
+ })
120
+ }
121
+
122
}), {
123
abort() { controller.abort() }
124
})