| 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, ApiHandlers } from './apiMiddleware' |
| 4 | import { |
| 5 | HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_PRECONDITION_FAILED |
| 6 | } from './const' |
| 7 | import _ from 'lodash' |
| 8 | import { getCertObject } from './listen' |
| 9 | import { getProjectInfo } from './github' |
| 10 | import { apiAssertTypes, haveTimeout, onlyTruthy, promiseBestEffort } from './misc' |
| 11 | import { lookup, Resolver } from 'dns/promises' |
| 12 | import { isIPv6 } from 'net' |
| 13 | import { getNatInfo, getPublicIps, getUpnpClient, mappedPort, upnpMappingParam } from './nat' |
| 14 | import { makeCert } from './acme' |
| 15 | import { selfCheck } from './selfCheck' |
| 16 | |
| 17 | export default { |
| 18 | get_nat: getNatInfo, |
| 19 | get_public_ips: getPublicIps, |
| 20 | |
| 21 | async check_domain({ domain }) { |
| 22 | apiAssertTypes({ string: { domain } }) |
| 23 | const resolver = new Resolver({ timeout: 3_000, tries: 2 }) |
| 24 | const prjInfo = await getProjectInfo() |
| 25 | resolver.setServers(prjInfo.dnsServers) |
| 26 | const timeout = 7_000 // the external timeout should be larger |
| 27 | const settled = await Promise.allSettled([ |
| 28 | haveTimeout(timeout, resolver.resolve(domain, 'A')), |
| 29 | haveTimeout(timeout, resolver.resolve(domain, 'AAAA')), |
| 30 | haveTimeout(timeout, lookup(domain).then(x => [x.address])), |
| 31 | ]) |
| 32 | if (settled[0].status === 'rejected' && settled[0].reason.code === 'ECONNREFUSED') |
| 33 | return new ApiError(HTTP_SERVICE_UNAVAILABLE, "cannot resolve domain") |
| 34 | // merge all results |
| 35 | const domainIps = _.uniq(onlyTruthy(settled.map(x => x.status === 'fulfilled' && x.value)).flat()) |
| 36 | if (!domainIps.length) |
| 37 | return new ApiError(HTTP_FAILED_DEPENDENCY, "domain not working") |
| 38 | const publicIps = await getPublicIps() // do this before stopping the server |
| 39 | for (const v6 of [false, true]) { |
| 40 | const domainIpsThisVersion = domainIps.filter(x => isIPv6(x) === v6) |
| 41 | const ipsThisVersion = publicIps.filter(x => isIPv6(x) === v6) |
| 42 | if (domainIpsThisVersion.length && ipsThisVersion.length && !_.intersection(domainIpsThisVersion, ipsThisVersion).length) |
| 43 | return new ApiError(HTTP_PRECONDITION_FAILED, `configure your domain to point to ${ipsThisVersion} (currently on ${domainIpsThisVersion[0]}) – a change can take hours to be effective`) |
| 44 | } |
| 45 | return {} |
| 46 | }, |
| 47 | |
| 48 | async map_port({ external, internal }) { |
| 49 | apiAssertTypes({ number_undefined: { external, internal } }) |
| 50 | const { upnp, externalPort, internalPort } = await getNatInfo() |
| 51 | if (!upnp) |
| 52 | return new ApiError(HTTP_SERVICE_UNAVAILABLE, "upnp failed") |
| 53 | if (!internalPort) |
| 54 | return new ApiError(HTTP_FAILED_DEPENDENCY, "no internal port") |
| 55 | if (externalPort) |
| 56 | try { await getUpnpClient().removeMapping({ public: { host: '', port: externalPort } }) } |
| 57 | catch (e: any) { return new ApiError(HTTP_SERVER_ERROR, "removeMapping failed: " + String(e) ) } |
| 58 | if (external) |
| 59 | await getUpnpClient().createMapping(upnpMappingParam(internal || internalPort, external)) |
| 60 | .catch(res => { |
| 61 | throw new ApiError(res.errorCode || HTTP_SERVER_ERROR, res.errorCode === 718 ? "Port not available" : res.errorDescription || res.message || "unknown error") |
| 62 | }) |
| 63 | mappedPort.set(external || 0) // remember only successful HFS mappings |
| 64 | return {} |
| 65 | }, |
| 66 | |
| 67 | async self_check({ url }) { |
| 68 | apiAssertTypes({ string_undefined: { url } }) |
| 69 | if (url) |
| 70 | return await selfCheck(url) |
| 71 | || new ApiError(HTTP_SERVICE_UNAVAILABLE) |
| 72 | const [publicIps, nat] = await Promise.all([getPublicIps(), getNatInfo()]) |
| 73 | if (!publicIps.length) |
| 74 | return new ApiError(HTTP_FAILED_DEPENDENCY, 'cannot detect public ip') |
| 75 | if (!nat.internalPort) |
| 76 | return new ApiError(HTTP_FAILED_DEPENDENCY, 'no internal port') |
| 77 | const finalPort = nat.externalPort || nat.internalPort |
| 78 | const proto = nat.proto || (getCertObject() ? 'https' : 'http') |
| 79 | const defPort = proto === 'https' ? 443 : 80 |
| 80 | const results = onlyTruthy(await promiseBestEffort(publicIps.map(ip => |
| 81 | selfCheck(`${proto}://${ip}${finalPort === defPort ? '' : ':' + finalPort}`) ))) |
| 82 | return results.length ? results : new ApiError(HTTP_SERVICE_UNAVAILABLE) |
| 83 | }, |
| 84 | |
| 85 | async make_cert({domain, email, altNames}) { |
| 86 | apiAssertTypes({ string: { domain }, string_undefined: { email }, array_undefined: { altNames } }) |
| 87 | if (altNames?.some((name: unknown) => typeof name !== 'string')) |
| 88 | return new ApiError(HTTP_BAD_REQUEST, 'bad altNames') |
| 89 | await makeCert(domain, email, altNames).catch(e => { |
| 90 | throw new ApiError(HTTP_SERVER_ERROR, e.message || String(e)) |
| 91 | }) |
| 92 | return {} |
| 93 | }, |
| 94 | |
| 95 | get_cert() { |
| 96 | return getCertObject() || { none: true } |
| 97 | } |
| 98 | } satisfies ApiHandlers |