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