main
ts 239 lines 8.77 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware'
4 import { configFile, defineConfig, getWholeConfig, setConfig } from './config'
5 import { getBaseUrlOrDefault, getIps, getServerStatus, getUrls } from './listen'
6 import {
7 API_VERSION, BUILD_TIMESTAMP, COMPATIBLE_API_VERSION, HFS_STARTED, IS_WINDOWS, VERSION,
8 HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR
9 } from './const'
10 import vfsApis from './api.vfs'
11 import accountsApis from './api.accounts'
12 import pluginsApis from './api.plugins'
13 import monitorApis from './api.monitor'
14 import langApis from './api.lang'
15 import netApis from './api.net'
16 import logApis from './api.log'
17 import certApis from './api.cert'
18 import { getConnections } from './connections'
19 import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, try_, typedEntries, waitFor } from './misc'
20 import { accountCanLoginAdmin, accounts } from './perm'
21 import Koa from 'koa'
22 import { cloudflareDetected, getProxyDetected } from './middlewares'
23 import { execFile } from 'child_process'
24 import { promisify } from 'util'
25 import { customHtmlSections, customHtml, saveCustomHtml, disableCustomHtml } from './customHtml'
26 import _ from 'lodash'
27 import {
28 autoCheckUpdateResult, getUpdates, getVersions, localUpdateAvailable, update, updateSupported, previousAvailable
29 } from './update'
30 import { resolve } from 'path'
31 import { getErrorSections } from './errorPages'
32 import { ip2country } from './geo'
33 import { roots } from './roots'
34 import { SendListReadable } from './SendList'
35 import { get_dynamic_dns_error } from './ddns'
36 import { addBlock, BlockingRule, isBlocked } from './block'
37 import { alerts, blacklistedInstalledPlugins, getProjectInfo } from './github'
38 import { acmeRenewError } from './acme'
39
40 export const adminApis = {
41
42 ...vfsApis,
43 ...accountsApis,
44 ...pluginsApis,
45 ...monitorApis,
46 ...langApis,
47 ...netApis,
48 ...logApis,
49 ...certApis,
50 get_dynamic_dns_error,
51
52 async set_config({ values }) {
53 apiAssertTypes({ object: { values } })
54 await setConfig(values)
55 if (values.port === 0 || values.https_port === 0)
56 return await waitFor(async () => {
57 const st = await getServerStatus()
58 // wait for all random ports to be done, so we communicate new numbers
59 if ((values.port !== 0 || st.http.listening)
60 && (values.https_port !== 0 || st.https.listening))
61 return st
62 }, { timeout: 1000 })
63 ?? new ApiError(HTTP_SERVER_ERROR, "something went wrong changing ports")
64 return {}
65 },
66
67 get_config: getWholeConfig,
68 get_config_text() {
69 return {
70 path: configFile.getPath(),
71 fullPath: resolve(configFile.getPath()),
72 text: configFile.getText(),
73 customHtml: customHtml.getText(),
74 }
75 },
76 set_config_text: ({ text }) => {
77 apiAssertTypes({ string: { text } })
78 return configFile.save(text, { reparse: true })
79 },
80 update: ({ tag }) => {
81 apiAssertTypes({ string_undefined: { tag } })
82 return update(tag).catch(e => {
83 throw e.cause?.statusCode ? new ApiError(e.cause?.statusCode) : e
84 })
85 },
86 async check_update() {
87 try {
88 return { options: await getUpdates() }
89 } catch (e: any) {
90 return new ApiError(HTTP_SERVER_ERROR, e?.message)
91 }
92 },
93 async get_other_versions() {
94 try {
95 return { options: await getVersions(r => !r.prerelease, 20) }
96 } catch (e: any) {
97 return new ApiError(HTTP_SERVER_ERROR, e?.message)
98 }
99 },
100 async wait_project_info() { // used by admin/home/check-for-updates
101 await getProjectInfo()
102 return {}
103 },
104
105 async ip_country({ ips }) {
106 apiAssertTypes({
107 array: { ips },
108 string: { ips0: ips[0] }
109 })
110 const res = await Promise.allSettled(ips.map(ip2country))
111 return {
112 codes: res.map(x => x.status === 'rejected' || x.value === '-' ? '' : x.value)
113 }
114 },
115 is_ip_blocked({ ips }) {
116 apiAssertTypes({
117 array: { ips },
118 string: { ips0: ips[0] }
119 })
120 return { blocked: ips.map((x: string) => isBlocked(x) ? 1 : 0) }
121 },
122
123 get_custom_html() {
124 return {
125 enabled: !disableCustomHtml.get(),
126 sections: Object.fromEntries([
127 ...customHtmlSections.concat(getErrorSections()).map(k => [k,'']), // be sure to output all sections
128 ...customHtml.sections // override entries above
129 ]),
130 }
131 },
132
133 async set_custom_html({ sections }) {
134 apiAssertTypes({ object: { sections } })
135 await saveCustomHtml(sections)
136 return {}
137 },
138
139 quit() {
140 setTimeout(() => process.exit())
141 return {}
142 },
143
144 async get_status() {
145 return {
146 started: HFS_STARTED,
147 build: BUILD_TIMESTAMP,
148 version: VERSION,
149 apiVersion: API_VERSION,
150 compatibleApiVersion: COMPATIBLE_API_VERSION,
151 ...await getServerStatus(false),
152 platform: process.platform,
153 cwd: process.cwd(),
154 configFile: configFile.getPath(),
155 urls: await getUrls(),
156 ips: await getIps(false),
157 baseUrl: await getBaseUrlOrDefault(),
158 roots: roots.get(),
159 anyAccountCanLoginAdmin: anyAccountCanLoginAdmin(),
160 updatePossible: !await updateSupported() ? false : (await localUpdateAvailable()) ? 'local' : true,
161 previousVersionAvailable: await previousAvailable(),
162 autoCheckUpdateResult: autoCheckUpdateResult.get(), // in this form, we get the same type of the serialized json
163 alerts,
164 proxyDetected: getProxyDetected(),
165 cloudflareDetected,
166 ram: process.memoryUsage.rss(),
167 acmeRenewError,
168 blacklistedInstalledPlugins,
169 frpDetected: localhostAdmin.get() && !getProxyDetected()
170 && getConnections().every(isLocalHost)
171 && await frpDebounced(),
172 }
173 },
174
175 async add_block({ merge, ip, expire, comment }: BlockingRule & { merge?: Partial<BlockingRule> }) {
176 apiAssertTypes({
177 string: { ip },
178 string_undefined: { comment, expire },
179 object_undefined: { merge },
180 })
181 const optionals = _.pickBy({ expire, comment }, v => v !== undefined) // passing undefined-s would override values in merge
182 addBlock({ ip, ...optionals }, merge)
183 return {}
184 },
185
186 async geo_ip({ ip }) {
187 apiAssertTypes({ string: { ip } })
188 return { country: await ip2country(ip) }
189 },
190
191 validate_net_mask({ mask }) {
192 apiAssertTypes({ string: { mask } })
193 return { result: Boolean(try_(() => makeNetMatcher(mask))) }
194 },
195
196 } satisfies ApiHandlers
197
198 for (const [k, was] of typedEntries(adminApis))
199 (adminApis[k] as any) = ((params, ctx) => {
200 if (ctxAdminAccess(ctx))
201 return was(params, ctx)
202 const props = { possible: anyAccountCanLoginAdmin() }
203 return ctx.headers.accept === 'text/event-stream'
204 ? new SendListReadable({ doAtStart: x => x.error(HTTP_UNAUTHORIZED, true, props) })
205 : new ApiError(HTTP_UNAUTHORIZED, props)
206 }) satisfies ApiHandler
207
208 export const localhostAdmin = defineConfig('localhost_admin', true)
209 export const adminNet = defineConfig('admin_net', '', v => makeNetMatcher(v, true) )
210 export const favicon = defineConfig('favicon', '')
211 export const title = defineConfig('title', "File server")
212
213 export function ctxAdminAccess(ctx: Koa.Context) {
214 if (preventAdminAccess(ctx))
215 return false
216 // whenProxyDetected covers both trusted and misconfigured proxies, so localhost_admin never trusts proxied localhost claims
217 return !ctx.state.whenProxyDetected && localhostAdmin.get() && isLocalHost(ctx)
218 && /^(?:|localhost|127\.0\.0\.1|\[::1])(?::\d+)?$/i.test(ctx.get('host')) // check Host to avoid DNS-rebinding attacks
219 || ctx.state.account && accountCanLoginAdmin(ctx.state.account)
220 }
221
222 const frpDebounced = debounceAsync(async () => {
223 if (!IS_WINDOWS) return false
224 try { // guy with win11 reported missing tasklist, so don't take it for granted
225 const { stdout } = await promisify(execFile)('tasklist', ['/fi','imagename eq frpc.exe','/nh'])
226 return stdout.includes('frpc')
227 }
228 catch {
229 return false
230 }
231 }, { retain: 10_000 })
232
233 export function anyAccountCanLoginAdmin() {
234 return _.some(accounts.get(), accountCanLoginAdmin)
235 }
236
237 export function preventAdminAccess(ctx: Koa.Context) {
238 return !isLocalHost(ctx) && !adminNet.compiled()(ctx.ip)
239 }