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